Files
restitution/tests/Unit.test.js
kaykayyali 8fc45968b5 M2.3 + M2.4 + TeamManager integration: projectile sprites, death handling, build menu, production panel, building placer
- ProjectileSprite.js: physics arcade sprite, faction tint, off-screen culling
- CombatSystem: refactored enemy selection to use TeamManager instead of legacy containers
- Death handling: DYING alpha tween (500ms), smoke puff (300ms), unit:killed event, cleanup
- TeamManager: centralized team registry replacing goodGuys/badGuys containers
- HealthBarSystem, ResourceBar, CaptureProgressUI, BuildMenu, BuildingPlacer, BuildingRenderer, ProductionPanel
- Map_Player: wired new subsystems, removed legacy container creation
- Tests: ProjectileSprite (4), DeathHandling (13), CombatSystem updated

47 tests passed at dev time (M2.3), 158/158 at dev time (M2.4)
2026-06-01 05:18:33 +00:00

309 lines
8.5 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 }))
},
teamManager: {
getTeamColor: jest.fn(() => 0x1d7196),
getTeamUnits: jest.fn(() => new Set()),
getAllUnits: jest.fn(() => []),
getEnemyUnits: jest.fn(() => new Set()),
isEnemy: jest.fn(() => false),
isSameTeam: jest.fn(() => true),
},
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',
teamId: '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.teamId).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();
});
});
});