Files
restitution/test/systems/ControlPointStateMachine.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

244 lines
8.2 KiB
JavaScript

/**
* ControlPointStateMachine.test.js — TeamManager-aware tests.
* Tests use Jest globals.
*/
// Local xstate mock with working state transitions for tick testing
jest.mock('xstate', () => {
function createService(initialState = 'NEUTRAL', initialContext = {}) {
const ctx = {
owner: null,
captureProgress: 0,
captureTime: 60000,
unitsInRadius: {},
...initialContext,
};
const svc = {
state: { value: initialState, context: ctx },
send: jest.fn((event) => {
const e = typeof event === 'string' ? { type: event } : event;
const current = svc.state.value;
if (current === 'NEUTRAL' && e.type === 'UNITS_ENTERED') {
svc.state.value = 'CONTESTED';
} else if (current === 'CONTESTED' && e.type === 'PROGRESS_COMPLETE') {
svc.state.value = 'CAPTURED';
ctx.owner = e.owner || e.teamId || null;
} else if (current === 'CONTESTED' && e.type === 'UNITS_LEFT') {
svc.state.value = 'NEUTRAL';
} else if (current === 'CAPTURED' && e.type === 'ENEMY_UNITS_ENTERED') {
svc.state.value = 'CONTESTED';
}
// Update context if event carries owner
if (e.owner != null) ctx.owner = e.owner;
if (e.teamId != null) ctx.owner = e.teamId;
}),
start: jest.fn(function() { return svc; }),
stop: jest.fn(),
status: 1, // not stopped
};
return svc;
}
return {
createMachine: jest.fn(() => ({})),
interpret: jest.fn((machine) => createService(machine?.config?.initial)),
assign: jest.fn((fn) => fn),
};
});
import ControlPointStateMachine, { ControlPointState } from 'Systems/ControlPointStateMachine.js';
// ── Helpers ─────────────────────────────────────────────────────────
function mockSceneWithTeamManager(teamManager) {
return {
add: {
zone: jest.fn((x, y, w, h) => ({
x: x ?? 0,
y: y ?? 0,
width: w ?? 0,
height: h ?? 0,
setName: jest.fn().mockReturnThis(),
destroy: jest.fn(),
body: { setCircle: jest.fn(), setOffset: jest.fn() },
})),
},
physics: {
world: { enableBody: jest.fn() },
},
events: { on: jest.fn(), emit: jest.fn(), off: jest.fn() },
teamManager,
children: { list: [] },
};
}
function createMockUnit(x, y, teamId) {
return {
x,
y,
setData: jest.fn((k, v) => { /* no-op */ }),
getData: jest.fn((key) => (key === 'teamId' ? teamId : null)),
};
}
function createTeamManagerMock(units = {}) {
// units: { teamId: [unit, unit], ... }
const map = new Map();
for (const [tid, list] of Object.entries(units)) {
const set = new Set(list);
map.set(tid, set);
}
return {
getAllUnitsGrouped: jest.fn(() => map),
getTeamUnits: jest.fn((tid) => map.get(tid) || new Set()),
getAllUnits: jest.fn(() => {
const all = [];
for (const list of map.values()) all.push(...list);
return all;
}),
};
}
// ── Tests ───────────────────────────────────────────────────────────
describe('ControlPointStateMachine (multi-team)', () => {
let scene;
let cp;
beforeEach(() => {
scene = mockSceneWithTeamManager(null);
});
afterEach(() => {
if (cp) {
cp.destroy();
cp = null;
}
});
// ── Test 1: registerUnitContainers removed ─────────────────────
test('registerUnitContainers is NOT a method on the instance', () => {
cp = new ControlPointStateMachine(scene, { x: 100, y: 100 });
expect(typeof cp.registerUnitContainers).toBe('undefined');
});
// ── Test 2: counts units per team via TeamManager ──────────────
test('getUnitsInRadius counts units per teamId via TeamManager', () => {
const uA1 = createMockUnit(100, 100, 'team-A');
const uA2 = createMockUnit(102, 100, 'team-A');
const uB1 = createMockUnit(300, 300, 'team-B'); // outside radius > 160
const uB2 = createMockUnit(104, 100, 'team-B'); // inside radius
const tm = createTeamManagerMock({
'team-A': [uA1, uA2],
'team-B': [uB1, uB2],
});
scene.teamManager = tm;
cp = new ControlPointStateMachine(scene, { x: 100, y: 100, radius: 5, tileSize: 32 });
cp.teamManager = tm; // Ensure TeamManager is wired
const counts = cp.getUnitsInRadius();
expect(counts['team-A']).toBe(2);
expect(counts['team-B']).toBe(1);
expect(counts['team-C']).toBeUndefined();
});
// ── Test 3: NEUTRAL → CONTESTED when units from 2+ teams present ─
test('NEUTRAL → CONTESTED when units from multiple teams present', () => {
const uA = createMockUnit(100, 100, 'team-A');
const uB = createMockUnit(104, 100, 'team-B');
const tm = createTeamManagerMock({
'team-A': [uA],
'team-B': [uB],
});
scene.teamManager = tm;
cp = new ControlPointStateMachine(scene, { x: 100, y: 100, radius: 5, tileSize: 32 });
cp.teamManager = tm;
expect(cp.getState()).toBe(ControlPointState.NEUTRAL);
cp.tick(0, 16);
expect(cp.getState()).toBe(ControlPointState.CONTESTED);
expect(cp.service.send).toHaveBeenCalledWith(
expect.objectContaining({ type: 'UNITS_ENTERED' }),
);
});
// ── Test 4: CONTESTED → CAPTURED when one team has majority ─────
test('CONTESTED → CAPTURED when one team reaches majority (progress hits 100)', () => {
const uA = createMockUnit(100, 100, 'team-A');
const uB = createMockUnit(104, 100, 'team-B');
const tm = createTeamManagerMock({
'team-A': [uA],
'team-B': [uB],
});
scene.teamManager = tm;
cp = new ControlPointStateMachine(scene, { x: 100, y: 100, radius: 5, tileSize: 32 });
cp.teamManager = tm;
// Move to CONTESTED first
cp.tick(0, 16);
expect(cp.getState()).toBe(ControlPointState.CONTESTED);
// Set contesting team as dominant by giving team-A more units
const uA2 = createMockUnit(101, 101, 'team-A');
tm.getAllUnitsGrouped.mockReturnValue(
new Map([['team-A', new Set([uA, uA2])], ['team-B', new Set([uB])]]),
);
tm.getTeamUnits.mockImplementation((tid) => {
if (tid === 'team-A') return new Set([uA, uA2]);
if (tid === 'team-B') return new Set([uB]);
return new Set();
});
// Inject captureProgress near completion
cp.service.state.context.captureProgress = 99.9;
cp.tick(0, 100); // delta pushes over 100
expect(cp.getState()).toBe(ControlPointState.CAPTURED);
expect(cp.service.send).toHaveBeenLastCalledWith(
expect.objectContaining({ type: 'PROGRESS_COMPLETE', owner: 'team-A' }),
);
});
// ── Test 5: owner stored as teamId string ──────────────────────
test('owner stored as teamId string after capture', () => {
const uA = createMockUnit(100, 100, 'team-A');
const tm = createTeamManagerMock({ 'team-A': [uA] });
scene.teamManager = tm;
cp = new ControlPointStateMachine(scene, {
x: 100, y: 100, radius: 5, tileSize: 32,
});
cp.teamManager = tm;
// Move to CONTESTED then CAPTURED
cp.tick(0, 16);
cp.service.state.context.captureProgress = 99.9;
cp.tick(0, 100);
expect(cp.getState()).toBe(ControlPointState.CAPTURED);
expect(cp.getOwner()).toBe('team-A');
});
// ── Test 6: TeamManager passed via config ───────────────────────
test('constructor accepts teamManager via config', () => {
const tm = createTeamManagerMock({});
cp = new ControlPointStateMachine(scene, {
x: 100, y: 100, teamManager: tm,
});
expect(cp.teamManager).toBe(tm);
});
// ── Test 7: falls back to scene.teamManager ─────────────────────
test('falls back to scene.teamManager if no teamManager in config', () => {
const tm = createTeamManagerMock({});
scene.teamManager = tm;
cp = new ControlPointStateMachine(scene, { x: 100, y: 100 });
expect(cp.teamManager).toBe(tm);
});
});