- Implemented 10 sub-systems (Economy, Pathfinding, Combat, Selection, Network, Map, Entity/Building/ControlPoint state machines, Orchestrator) - Refactored Custom_Entity.js → Unit.js with 5 components (health, owner, inventory, movement, combat) - Added Jest test suite with 100+ tests (EconomySystem 100%, EntityStateMachine 100%, PathfindingSystem 99%, Unit.js 72%) - All webpack builds pass (0 errors) - BMAD-auto team-respawn flow: 10 parallel sub-agents implemented systems Architecture: Phaser 3 + XState + socket.io + EasyStar Mode: team-respawn Model: custom/ollama-cloud-pro
281 lines
7.2 KiB
JavaScript
281 lines
7.2 KiB
JavaScript
/**
|
|
* Unit Entity Unit Tests
|
|
*/
|
|
jest.mock('Systems/EntityStateMachine', () => ({
|
|
forEntity: jest.fn(() => ({
|
|
tick: jest.fn(),
|
|
send: jest.fn(),
|
|
destroy: jest.fn(),
|
|
getState: jest.fn(() => 'IDLING')
|
|
}))
|
|
}));
|
|
|
|
import Unit from '../src/entities/Unit';
|
|
import EntityStateMachine from 'Systems/EntityStateMachine';
|
|
|
|
const createMockScene = () => ({
|
|
add: {
|
|
existing: jest.fn()
|
|
},
|
|
physics: {
|
|
world: {
|
|
enableBody: jest.fn()
|
|
}
|
|
},
|
|
interface: {
|
|
generateWorldXY: jest.fn(tile => ({ x: tile.x * 64, y: tile.y * 64 }))
|
|
},
|
|
orchestrator: {
|
|
systems: {
|
|
EntityStateMachine: { forEntity: jest.fn() },
|
|
combat: { fireProjectile: jest.fn() },
|
|
pathfinding: { findPath: jest.fn() },
|
|
selection: { add: jest.fn() }
|
|
}
|
|
},
|
|
events: {
|
|
emit: jest.fn()
|
|
},
|
|
tweens: {
|
|
addCounter: jest.fn(() => ({ stop: jest.fn() }))
|
|
}
|
|
});
|
|
|
|
describe('Unit', () => {
|
|
let scene;
|
|
let unit;
|
|
|
|
beforeEach(() => {
|
|
scene = createMockScene();
|
|
unit = new Unit(scene, 'tank_texture', { x: 5, y: 5 }, {
|
|
maxHp: 100,
|
|
armor: 5,
|
|
playerId: 'player1',
|
|
team: 'good',
|
|
weaponRange: 200,
|
|
damage: 25
|
|
});
|
|
});
|
|
|
|
describe('Component Access', () => {
|
|
it('should have health component', () => {
|
|
const health = unit.getComponent('health');
|
|
|
|
expect(health.maxHp).toBe(100);
|
|
expect(health.current).toBe(100);
|
|
expect(health.armor).toBe(5);
|
|
});
|
|
|
|
it('should have owner component', () => {
|
|
const owner = unit.getComponent('owner');
|
|
|
|
expect(owner.playerId).toBe('player1');
|
|
expect(owner.team).toBe('good');
|
|
});
|
|
|
|
it('should have combat component', () => {
|
|
const combat = unit.getComponent('combat');
|
|
|
|
expect(combat.weaponRange).toBe(200);
|
|
expect(combat.damage).toBe(25);
|
|
});
|
|
|
|
it('should update component with setComponent', () => {
|
|
unit.setComponent('health', { current: 50 });
|
|
|
|
const health = unit.getComponent('health');
|
|
expect(health.current).toBe(50);
|
|
expect(health.maxHp).toBe(100); // Unchanged
|
|
});
|
|
});
|
|
|
|
describe('Damage System', () => {
|
|
it('should apply damage with armor reduction', () => {
|
|
const damageTaken = unit.damage(30, 'rifle');
|
|
|
|
expect(damageTaken).toBeLessThanOrEqual(25); // 30 - 5 armor
|
|
expect(unit.getComponent('health').current).toBeLessThan(100);
|
|
});
|
|
|
|
it('should apply minimum 1 damage', () => {
|
|
const damageTaken = unit.damage(2, 'rifle');
|
|
|
|
expect(damageTaken).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('should emit unit:damaged event', () => {
|
|
unit.damage(20);
|
|
|
|
expect(scene.events.emit).toHaveBeenCalledWith(
|
|
'unit:damaged',
|
|
expect.objectContaining({
|
|
unit: unit,
|
|
amount: expect.any(Number),
|
|
remaining: expect.any(Number)
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should mark unit as dead when health reaches 0', () => {
|
|
unit.getComponent('health').current = 5;
|
|
unit.damage(10);
|
|
|
|
expect(unit.dead).toBe(true);
|
|
});
|
|
|
|
it('should not damage if already dead', () => {
|
|
unit.dead = true;
|
|
const damageTaken = unit.damage(50);
|
|
|
|
expect(damageTaken).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Heal System', () => {
|
|
it('should heal unit', () => {
|
|
unit.damage(30);
|
|
const healed = unit.heal(20);
|
|
|
|
expect(healed).toBe(20);
|
|
expect(unit.getComponent('health').current).toBe(90);
|
|
});
|
|
|
|
it('should not exceed max HP', () => {
|
|
const healed = unit.heal(50);
|
|
|
|
expect(unit.getComponent('health').current).toBe(100); // Capped at max
|
|
});
|
|
});
|
|
|
|
describe('Combat', () => {
|
|
let target;
|
|
|
|
beforeEach(() => {
|
|
target = {
|
|
x: 150,
|
|
y: 0,
|
|
isDead: jest.fn(() => false),
|
|
getData: jest.fn(() => ({ playerId: 'enemy' }))
|
|
};
|
|
});
|
|
|
|
it('should return true when target in range', () => {
|
|
expect(unit.canHitBody(target)).toBe(true);
|
|
});
|
|
|
|
it('should return false when target out of range', () => {
|
|
target.x = 300; // Beyond 200 range
|
|
expect(unit.canHitBody(target)).toBe(false);
|
|
});
|
|
|
|
it('should return false when target is dead', () => {
|
|
target.isDead = jest.fn(() => true);
|
|
expect(unit.canHitBody(target)).toBe(false);
|
|
});
|
|
|
|
it('should attack target when in range', () => {
|
|
const result = unit.attackTarget(target);
|
|
|
|
expect(result).toBe(true);
|
|
expect(scene.orchestrator.systems.combat.fireProjectile).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not attack when target out of range', () => {
|
|
target.x = 300;
|
|
const result = unit.attackTarget(target);
|
|
|
|
expect(result).toBe(false);
|
|
expect(scene.orchestrator.systems.combat.fireProjectile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should respect fire rate', () => {
|
|
unit.attackTarget(target);
|
|
const result2 = unit.attackTarget(target);
|
|
|
|
// Second attack should fail due to fire rate cooldown
|
|
expect(result2).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Selection', () => {
|
|
it('should select unit', () => {
|
|
unit.select();
|
|
|
|
expect(unit.getData('selected')).toBe(true);
|
|
expect(scene.tweens.addCounter).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should unselect unit', () => {
|
|
unit.select();
|
|
unit.unSelect();
|
|
|
|
expect(unit.getData('selected')).toBe(false);
|
|
});
|
|
|
|
it('should tint based on team', () => {
|
|
unit.select();
|
|
|
|
expect(unit.setTint).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Movement', () => {
|
|
it('should move to tile', () => {
|
|
const result = unit.moveToTile({ x: 10, y: 10 });
|
|
|
|
expect(result).toBe(true);
|
|
expect(unit.setPosition).toHaveBeenCalledWith(640, 640); // 10 * 64
|
|
});
|
|
|
|
it('should set target tile data', () => {
|
|
unit.moveToTile({ x: 10, y: 10 });
|
|
|
|
expect(unit.getData('targetTile')).toEqual({ x: 10, y: 10 });
|
|
});
|
|
|
|
it('should orient to target', () => {
|
|
const target = { x: 100, y: 0 };
|
|
unit.orientToTarget(target);
|
|
|
|
expect(unit.setFlipX).toHaveBeenCalledWith(true); // EAST direction
|
|
});
|
|
});
|
|
|
|
describe('State Machine', () => {
|
|
it('should initialize state machine', () => {
|
|
expect(unit.stateMachine).toBeDefined();
|
|
expect(scene.orchestrator.systems.EntityStateMachine.forEntity).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should tick state machine in preUpdate', () => {
|
|
const tickSpy = jest.spyOn(unit.stateMachine, 'tick');
|
|
|
|
unit.preUpdate(Date.now(), 16);
|
|
|
|
expect(tickSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Death', () => {
|
|
it('should trigger death when health reaches 0', () => {
|
|
const dieSpy = jest.spyOn(unit.stateMachine, 'send');
|
|
|
|
unit.getComponent('health').current = 5;
|
|
unit.damage(10);
|
|
|
|
expect(dieSpy).toHaveBeenCalledWith('DIE');
|
|
expect(scene.events.emit).toHaveBeenCalledWith('unit:dying', expect.anything());
|
|
});
|
|
|
|
it('should cleanup on destroy', () => {
|
|
unit.pulse = { stop: jest.fn() };
|
|
const destroySpy = jest.spyOn(unit.stateMachine, 'destroy');
|
|
|
|
unit.destroy();
|
|
|
|
expect(unit.pulse.stop).toHaveBeenCalled();
|
|
expect(destroySpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|