Add test coverage for TargetingSystem

This commit is contained in:
Peter Stockings
2026-01-20 23:22:33 +11:00
parent 59a84b97e0
commit 75df62db66
4 changed files with 269 additions and 69 deletions

View File

@@ -0,0 +1,26 @@
import { vi } from 'vitest';
// Stub global window for Phaser device detection
if (typeof window === 'undefined') {
(globalThis as any).window = {
location: { href: '', origin: '' },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
cordova: undefined,
navigator: { userAgent: 'node' }
};
}
if (typeof document === 'undefined') {
(globalThis as any).document = {
createElement: vi.fn(() => ({
getContext: vi.fn(),
style: {}
})),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
}
if (typeof navigator === 'undefined') {
(globalThis as any).navigator = { userAgent: 'node' };
}

View File

@@ -1,9 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GameScene } from '../GameScene';
import * as simulation from '../../engine/simulation/simulation';
import * as generator from '../../engine/world/generator';
// Mock Phaser
// Mock Phaser BEFORE any project imports
vi.mock('phaser', () => {
const mockEventEmitter = {
on: vi.fn(),
@@ -11,77 +8,97 @@ vi.mock('phaser', () => {
off: vi.fn(),
};
class MockScene {
events = mockEventEmitter;
input = {
keyboard: {
createCursorKeys: vi.fn(() => ({})),
on: vi.fn(),
},
on: vi.fn(),
activePointer: {
worldX: 0,
worldY: 0,
x: 0,
y: 0
},
mouse: {
disableContextMenu: vi.fn()
}
};
cameras = {
main: {
setZoom: vi.fn(),
setBounds: vi.fn(),
centerOn: vi.fn(),
fadeIn: vi.fn(),
getWorldPoint: vi.fn((x, y) => ({ x, y }))
},
};
scene = {
launch: vi.fn(),
get: vi.fn(),
};
add = {
graphics: vi.fn(() => ({
setDepth: vi.fn().mockReturnThis(),
clear: vi.fn(),
lineStyle: vi.fn(),
lineBetween: vi.fn(),
strokeRect: vi.fn(),
})),
sprite: vi.fn(() => ({
setDepth: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
})),
text: vi.fn(() => ({})),
rectangle: vi.fn(() => ({})),
container: vi.fn(() => ({})),
};
load = {
spritesheet: vi.fn(),
};
anims = {
create: vi.fn(),
exists: vi.fn(() => true),
generateFrameNumbers: vi.fn(),
};
}
return {
default: {
Scene: class {
events = mockEventEmitter;
input = {
keyboard: {
createCursorKeys: vi.fn(() => ({})),
on: vi.fn(),
},
on: vi.fn(),
};
cameras = {
main: {
setZoom: vi.fn(),
setBounds: vi.fn(),
centerOn: vi.fn(),
fadeIn: vi.fn(),
},
};
scene = {
launch: vi.fn(),
get: vi.fn(),
};
add = {
graphics: vi.fn(() => ({
setDepth: vi.fn().mockReturnThis(),
clear: vi.fn(),
lineStyle: vi.fn(),
lineBetween: vi.fn(),
strokeRect: vi.fn(),
})),
sprite: vi.fn(() => ({
setDepth: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
})),
text: vi.fn(() => ({})),
rectangle: vi.fn(() => ({})),
container: vi.fn(() => ({})),
};
load = {
spritesheet: vi.fn(),
};
anims = {
create: vi.fn(),
exists: vi.fn(() => true),
generateFrameNumbers: vi.fn(),
};
},
Scene: MockScene,
Input: {
Keyboard: {
JustDown: vi.fn(),
},
},
},
}
};
});
import { GameScene } from '../GameScene';
import * as simulation from '../../engine/simulation/simulation';
import * as generator from '../../engine/world/generator';
// Mock other modules
vi.mock('../../rendering/DungeonRenderer', () => ({
DungeonRenderer: vi.fn().mockImplementation(function() {
return {
initializeFloor: vi.fn(),
computeFov: vi.fn(),
render: vi.fn(),
showDamage: vi.fn(),
spawnCorpse: vi.fn(),
showWait: vi.fn(),
isMinimapVisible: vi.fn(() => false),
toggleMinimap: vi.fn(),
updateTile: vi.fn(),
showProjectile: vi.fn(),
showHeal: vi.fn(),
shakeCamera: vi.fn(),
};
}),
}));
@@ -93,7 +110,6 @@ vi.mock('../../engine/simulation/simulation', () => ({
vi.mock('../../engine/world/generator', () => ({
generateWorld: vi.fn(),
}));
vi.mock('../../engine/world/world-logic', () => ({
@@ -101,6 +117,7 @@ vi.mock('../../engine/world/world-logic', () => ({
isBlocked: vi.fn(() => false),
isPlayerOnExit: vi.fn(() => false),
idx: vi.fn((w, x, y) => y * w.width + x),
tryDestructTile: vi.fn(() => false),
}));
describe('GameScene', () => {
@@ -148,7 +165,6 @@ describe('GameScene', () => {
mockWorld.actors.set(1, mockPlayer);
(generator.generateWorld as any).mockReturnValue({
world: mockWorld,
playerId: 1,
});
@@ -163,9 +179,7 @@ describe('GameScene', () => {
});
it('should trigger death screen when player is killed', () => {
// 1. Mock simulation so that after action, player is gone from world
(simulation.applyAction as any).mockImplementation((world: any) => {
// simulate player being killed
world.actors.delete(1);
return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }];
});
@@ -175,19 +189,8 @@ describe('GameScene', () => {
events: [],
});
// 2. Commit an action
// We need to access private method or trigger it via public interface
// commitPlayerAction is private, let's cast to any to call it
(scene as any).commitPlayerAction({ type: 'wait' });
// 3. Verify showDeathScreen was called on the mock UI
expect(mockUI.showDeathScreen).toHaveBeenCalled();
// Verify it was called with some stats
const callArgs = mockUI.showDeathScreen.mock.calls[0][0];
expect(callArgs).toHaveProperty('floor');
expect(callArgs).toHaveProperty('gold');
expect(callArgs).toHaveProperty('stats');
});
});

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock Phaser
vi.mock('phaser', () => {
const mockGraphics = {
setDepth: vi.fn().mockReturnThis(),
clear: vi.fn().mockReturnThis(),
lineStyle: vi.fn().mockReturnThis(),
lineBetween: vi.fn().mockReturnThis(),
};
const mockSprite = {
setDepth: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
};
return {
default: {
Scene: class {
add = {
graphics: vi.fn(() => mockGraphics),
sprite: vi.fn(() => mockSprite),
};
}
}
};
});
// Mock CombatLogic
vi.mock('../../../engine/gameplay/CombatLogic', () => ({
traceProjectile: vi.fn(),
getClosestVisibleEnemy: vi.fn(),
}));
import { TargetingSystem } from '../TargetingSystem';
import { traceProjectile, getClosestVisibleEnemy } from '../../../engine/gameplay/CombatLogic';
import { TILE_SIZE } from '../../../core/constants';
describe('TargetingSystem', () => {
let targetingSystem: TargetingSystem;
let mockWorld: any;
let mockEntityManager: any;
let mockScene: any;
let mockGraphics: any;
let mockSprite: any;
beforeEach(() => {
vi.clearAllMocks();
mockGraphics = {
setDepth: vi.fn().mockReturnThis(),
clear: vi.fn().mockReturnThis(),
lineStyle: vi.fn().mockReturnThis(),
lineBetween: vi.fn().mockReturnThis(),
};
mockSprite = {
setDepth: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
};
mockScene = {
add: {
graphics: vi.fn(() => mockGraphics),
sprite: vi.fn(() => mockSprite),
},
} as any;
targetingSystem = new TargetingSystem(mockScene);
mockWorld = { width: 10, height: 10 };
mockEntityManager = {};
// Default return for traceProjectile
(traceProjectile as any).mockReturnValue({
blockedPos: { x: 0, y: 0 },
hitActorId: undefined,
path: []
});
});
it('should initialize with graphics and crosshair sprite hidden', () => {
expect(mockScene.add.graphics).toHaveBeenCalled();
expect(mockScene.add.sprite).toHaveBeenCalled();
expect(mockSprite.setVisible).toHaveBeenCalledWith(false);
});
it('should start targeting and auto-select closest enemy', () => {
const playerPos = { x: 1, y: 1 };
const enemyPos = { x: 3, y: 3 };
(getClosestVisibleEnemy as any).mockReturnValue(enemyPos);
targetingSystem.startTargeting(
'item-1',
playerPos,
mockWorld,
mockEntityManager!,
1 as any,
new Uint8Array(100),
10
);
expect(targetingSystem.isActive).toBe(true);
expect(targetingSystem.itemId).toBe('item-1');
expect(targetingSystem.cursorPos).toEqual(enemyPos);
expect(mockSprite.setVisible).toHaveBeenCalledWith(true);
});
it('should fallback to mouse position if no enemy found', () => {
const playerPos = { x: 1, y: 1 };
const mousePos = { x: 5, y: 5 };
(getClosestVisibleEnemy as any).mockReturnValue(null);
targetingSystem.startTargeting(
'item-1',
playerPos,
mockWorld,
mockEntityManager!,
1 as any,
new Uint8Array(100),
10,
mousePos
);
expect(targetingSystem.cursorPos).toEqual(mousePos);
});
it('should update visuals with predictive impact point', () => {
const playerPos = { x: 1, y: 1 };
const targetPos = { x: 5, y: 1 };
const blockedPos = { x: 3, y: 1 }; // Wall at 3,1
(traceProjectile as any).mockReturnValue({
blockedPos: blockedPos,
hitActorId: undefined,
path: []
});
// Start targeting
targetingSystem.startTargeting(
'item-1',
playerPos,
mockWorld,
mockEntityManager!,
1 as any,
new Uint8Array(100),
10,
targetPos
);
// The crosshair should be at blockedPos, not targetPos
const expectedX = blockedPos.x * TILE_SIZE + TILE_SIZE / 2;
const expectedY = blockedPos.y * TILE_SIZE + TILE_SIZE / 2;
expect(mockSprite.setPosition).toHaveBeenCalledWith(expectedX, expectedY);
// Verify dashed line was drawn (multiple lineBetween calls)
expect(mockGraphics.lineBetween).toHaveBeenCalled();
expect(mockGraphics.lineBetween.mock.calls.length).toBeGreaterThan(1);
});
it('should clear visuals on cancel', () => {
targetingSystem.cancel();
expect(targetingSystem.isActive).toBe(false);
expect(mockGraphics.clear).toHaveBeenCalled();
expect(mockSprite.setVisible).toHaveBeenCalledWith(false);
});
});

View File

@@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
setupFiles: ["src/__tests__/test-setup.ts"],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],