Files
restitution/tests/Unit.test.js
kaykayyali 3fc29f728e feat: Colyseus authoritative server + invite-code lobby
- Replace socket.io relay with Colyseus 0.15 authoritative server
- GameRoom with GameState schema (players, units, resources)
- Pure TS services: CombatResolver, EconomyService, PathfindingService, UnitManager
- POST /api/create-room → 4-char invite code
- React/MUI LobbyScreen: Create (shows code + START GAME) / Join by code
- ColyseusClient: joinOrCreate/join by room type = invite code
- Nginx: static assets direct, all else proxied to Colyseus (WS upgrade)
- Content-hashed JS bundles for Cloudflare cache-busting
- 1-player lobbies: START GAME button bypasses 2-player wait
2026-05-30 02:49:20 +00:00

301 lines
8.3 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,
combat: { fireProjectile: jest.fn() },
pathfinding: { findPath: jest.fn() },
selection: { add: jest.fn() }
}
},
events: {
emit: jest.fn()
},
tweens: {
// Fire onUpdate immediately so selection tests see setTint called
addCounter: jest.fn(config => {
const tween = { getValue: () => 200, stop: jest.fn() };
if (config.onUpdate) config.onUpdate(tween);
return tween;
})
}
});
describe('Unit', () => {
let scene;
let unit;
beforeEach(() => {
jest.clearAllMocks();
scene = createMockScene();
// Start unit at tile (0,0) = world (0,0) so distance calculations are simple
unit = new Unit(scene, 'tank_texture', { x: 0, y: 0 }, {
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');
// effectiveArmor = 5 * (1-0) = 5, finalDamage = max(1, 30-5) = 25
expect(damageTaken).toBe(25);
expect(unit.getComponent('health').current).toBe(75);
});
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', () => {
// damage(30): armor 5, AP 0 → effectiveArmor = 5, finalDamage = max(1, 25) = 25
// health: 100 → 75
unit.damage(30);
const healed = unit.heal(20);
expect(healed).toBe(20);
// 75 + 20 = 95 (capped at maxHp: 100, so stays at 95)
expect(unit.getComponent('health').current).toBe(95);
});
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', () => {
// unit at (0,0), target at (150,0) → distance 150 ≤ 200 weaponRange
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', () => {
// Team 'good' → isEnemy = false → setTint with (0, 200, 0, 255)
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', () => {
// unit at (0,0), target at (0, 100) → BELOW → direction = SOUTH (180°)
// RadToDeg normalizes to [0,360), 180° is EAST or SOUTH?
// getDirection: degrees >= 180 && < 270 → 'SOUTH' → setFlipX(true)
const target = { x: 0, y: 100 };
unit.orientToTarget(target);
// atan2(100, 0) = π/2 → 90° → 'EAST' range (90-180)
// Actually: atan2(y2-y1, x2-x1) = atan2(100, 0) = π/2 = 90°
// 90° is in range [90, 180) → 'EAST' → shouldFlip = true
expect(unit.setFlipX).toHaveBeenCalledWith(true);
});
});
describe('State Machine', () => {
it('should initialize state machine', () => {
expect(unit.stateMachine).toBeDefined();
// Constructor calls EntityStateMachine.forEntity (module-level mock, not scene)
expect(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();
});
});
});