- 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
235 lines
7.4 KiB
JavaScript
235 lines
7.4 KiB
JavaScript
/**
|
|
* EntityStateMachine.test.js — Tests for state transitions, event sending, lifecycle.
|
|
*/
|
|
|
|
import EntityStateMachine from 'Systems/EntityStateMachine.js';
|
|
|
|
describe('EntityStateMachine', () => {
|
|
let mockEntity;
|
|
let machineConfig;
|
|
|
|
beforeEach(() => {
|
|
mockEntity = {
|
|
name: 'testEntity',
|
|
x: 100,
|
|
y: 200,
|
|
rotation: 0,
|
|
};
|
|
|
|
machineConfig = {
|
|
id: 'entity',
|
|
initial: 'IDLING',
|
|
context: {},
|
|
states: {
|
|
IDLING: {
|
|
entry: ['playIdleAnim'],
|
|
on: { MOVE: 'MOVING', DIE: 'DYING' },
|
|
},
|
|
MOVING: {
|
|
entry: ['playMoveAnim'],
|
|
on: { ARRIVED: 'IDLING', DIE: 'DYING' },
|
|
},
|
|
DYING: {
|
|
type: 'final',
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
// ── Constructor ─────────────────────────────────────────────────
|
|
describe('constructor', () => {
|
|
test('stores entity and machine config', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
|
|
expect(esm.entity).toBe(mockEntity);
|
|
expect(esm.machineConfig).toBe(machineConfig);
|
|
expect(esm.service).toBeNull();
|
|
});
|
|
|
|
test('default initial state is IDLING', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
expect(esm._currentState).toBe('IDLING');
|
|
expect(esm.getState()).toBe('IDLING');
|
|
});
|
|
});
|
|
|
|
// ── send ────────────────────────────────────────────────────────
|
|
describe('send', () => {
|
|
test('sends event to service when available', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
const mockSend = jest.fn();
|
|
|
|
esm.service = {
|
|
send: mockSend,
|
|
state: { value: 'IDLING' },
|
|
};
|
|
|
|
esm.send('MOVE');
|
|
expect(mockSend).toHaveBeenCalledWith({ type: 'MOVE' });
|
|
});
|
|
|
|
test('sends event with context', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
const mockSend = jest.fn();
|
|
|
|
esm.service = {
|
|
send: mockSend,
|
|
state: { value: 'IDLING' },
|
|
};
|
|
|
|
esm.send('MOVE', { x: 10, y: 20 });
|
|
expect(mockSend).toHaveBeenCalledWith({ type: 'MOVE', x: 10, y: 20 });
|
|
});
|
|
|
|
test('does not throw when service is null', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
expect(() => esm.send('MOVE')).not.toThrow();
|
|
});
|
|
|
|
test('does not throw when service has no send method', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
esm.service = {};
|
|
expect(() => esm.send('MOVE')).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ── getState ────────────────────────────────────────────────────
|
|
describe('getState', () => {
|
|
test('returns service state value when available', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
|
|
esm.service = {
|
|
state: { value: 'MOVING' },
|
|
send: jest.fn(),
|
|
};
|
|
|
|
expect(esm.getState()).toBe('MOVING');
|
|
});
|
|
|
|
test('falls back to _currentState when service is null', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
esm._currentState = 'ATTACKING';
|
|
expect(esm.getState()).toBe('ATTACKING');
|
|
});
|
|
|
|
test('falls back to _currentState when service.state is null', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
|
|
esm.service = {
|
|
state: null,
|
|
send: jest.fn(),
|
|
};
|
|
esm._currentState = 'DYING';
|
|
|
|
expect(esm.getState()).toBe('DYING');
|
|
});
|
|
});
|
|
|
|
// ── state transitions ──────────────────────────────────────────
|
|
describe('state transitions', () => {
|
|
test('starts in IDLING', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
expect(esm.getState()).toBe('IDLING');
|
|
});
|
|
|
|
test('can simulate state changes via send', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
const mockSend = jest.fn();
|
|
|
|
// Simulate a service that tracks state
|
|
let currentState = 'IDLING';
|
|
const stateMap = {
|
|
IDLING: { MOVE: 'MOVING', DIE: 'DYING' },
|
|
MOVING: { ARRIVED: 'IDLING', DIE: 'DYING' },
|
|
DYING: {},
|
|
};
|
|
|
|
esm.service = {
|
|
send: (event) => {
|
|
mockSend(event);
|
|
const transitions = stateMap[currentState];
|
|
if (transitions && transitions[event.type]) {
|
|
currentState = transitions[event.type];
|
|
}
|
|
},
|
|
state: { value: currentState },
|
|
};
|
|
|
|
expect(esm.getState()).toBe('IDLING');
|
|
|
|
esm.send('MOVE');
|
|
expect(currentState).toBe('MOVING');
|
|
});
|
|
});
|
|
|
|
// ── tick ────────────────────────────────────────────────────────
|
|
describe('tick', () => {
|
|
test('does not throw when called', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
expect(() => esm.tick(1000, 16)).not.toThrow();
|
|
});
|
|
|
|
test('can be called multiple times without side effects', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
for (let i = 0; i < 10; i++) {
|
|
expect(() => esm.tick(i * 100, 16)).not.toThrow();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── destroy ─────────────────────────────────────────────────────
|
|
describe('destroy', () => {
|
|
test('stops service if it has a stop method', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
const mockStop = jest.fn();
|
|
|
|
esm.service = {
|
|
stop: mockStop,
|
|
send: jest.fn(),
|
|
};
|
|
|
|
esm.destroy();
|
|
|
|
expect(mockStop).toHaveBeenCalledTimes(1);
|
|
expect(esm.service).toBeNull();
|
|
expect(esm.entity).toBeNull();
|
|
});
|
|
|
|
test('handles service without stop method gracefully', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
esm.service = { send: jest.fn() };
|
|
expect(() => esm.destroy()).not.toThrow();
|
|
expect(esm.service).toBeNull();
|
|
});
|
|
|
|
test('handles null service gracefully', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
expect(() => esm.destroy()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ── edge cases ──────────────────────────────────────────────────
|
|
describe('edge cases', () => {
|
|
test('handles empty machineConfig', () => {
|
|
const esm = new EntityStateMachine(mockEntity, {});
|
|
expect(esm.machineConfig).toEqual({});
|
|
expect(esm.getState()).toBe('IDLING');
|
|
});
|
|
|
|
test('handles rapid send calls', () => {
|
|
const esm = new EntityStateMachine(mockEntity, machineConfig);
|
|
const events = [];
|
|
esm.service = {
|
|
send: (e) => events.push(e.type),
|
|
state: { value: 'IDLING' },
|
|
};
|
|
|
|
esm.send('MOVE');
|
|
esm.send('DIE');
|
|
esm.send('ARRIVED');
|
|
|
|
expect(events).toEqual(['MOVE', 'DIE', 'ARRIVED']);
|
|
});
|
|
});
|
|
});
|