feat: add TaskDispatcher tests for task assignment and management
This commit is contained in:
222
server/__tests__/TaskDispatcher.test.js
Normal file
222
server/__tests__/TaskDispatcher.test.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { TaskDispatcher } from '../TaskDispatcher.js';
|
||||||
|
|
||||||
|
// ========== Mock Factories ==========
|
||||||
|
|
||||||
|
function makeTurtle(id, { connected = true, state = 'idle', fuel = 1000, position = null } = {}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
connected,
|
||||||
|
stateName: state,
|
||||||
|
_fuel: fuel,
|
||||||
|
_error: null,
|
||||||
|
position,
|
||||||
|
setState: vi.fn(function (name) { this.stateName = name; }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDb() {
|
||||||
|
const tasks = [];
|
||||||
|
let nextId = 1;
|
||||||
|
return {
|
||||||
|
createTask: vi.fn((type, data, priority, turtleId) => {
|
||||||
|
const id = nextId++;
|
||||||
|
tasks.push({ id, task_type: type, task_data: data, priority, assigned_turtle_id: turtleId, status: 'pending' });
|
||||||
|
return id;
|
||||||
|
}),
|
||||||
|
getAllTasks: vi.fn((status) => {
|
||||||
|
return tasks
|
||||||
|
.filter(t => !status || t.status === status)
|
||||||
|
.sort((a, b) => b.priority - a.priority || a.id - b.id);
|
||||||
|
}),
|
||||||
|
getNextTask: vi.fn(() => tasks.find(t => t.status === 'pending') || null),
|
||||||
|
assignTask: vi.fn((taskId, turtleId) => {
|
||||||
|
const t = tasks.find(x => x.id === taskId);
|
||||||
|
if (t) {
|
||||||
|
t.assigned_turtle_id = turtleId;
|
||||||
|
t.status = turtleId === null ? 'pending' : 'assigned';
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
updateTaskStatus: vi.fn((taskId, status) => {
|
||||||
|
const t = tasks.find(x => x.id === taskId);
|
||||||
|
if (t) t.status = status;
|
||||||
|
}),
|
||||||
|
completeTask: vi.fn((taskId) => {
|
||||||
|
const t = tasks.find(x => x.id === taskId);
|
||||||
|
if (t) t.status = 'completed';
|
||||||
|
}),
|
||||||
|
_tasks: tasks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Tests ==========
|
||||||
|
|
||||||
|
describe('TaskDispatcher', () => {
|
||||||
|
let turtles, db, broadcast, dispatcher;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
turtles = new Map();
|
||||||
|
db = makeDb();
|
||||||
|
broadcast = vi.fn();
|
||||||
|
dispatcher = new TaskDispatcher({ turtles, db, broadcastToClients: broadcast, pollInterval: 60000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch when no turtles are available', () => {
|
||||||
|
db.createTask('mine_area', { bounds: { minX: 0, minY: 0, minZ: 0, maxX: 10, maxY: 10, maxZ: 10 } }, 5, null);
|
||||||
|
dispatcher._tick();
|
||||||
|
expect(db.assignTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should assign a pending task to an idle turtle', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('mine_area', { bounds: { minX: 0, minY: 0, minZ: 0, maxX: 10, maxY: 10, maxZ: 10 } }, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(db.assignTask).toHaveBeenCalledWith(1, 1);
|
||||||
|
expect(db.updateTaskStatus).toHaveBeenCalledWith(1, 'in_progress');
|
||||||
|
expect(turtle.setState).toHaveBeenCalledWith('mining', expect.objectContaining({ bounds: expect.any(Object) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect turtle assignment on tasks', () => {
|
||||||
|
const t1 = makeTurtle(1);
|
||||||
|
const t2 = makeTurtle(2);
|
||||||
|
turtles.set(1, t1);
|
||||||
|
turtles.set(2, t2);
|
||||||
|
|
||||||
|
// Task assigned specifically to turtle 2
|
||||||
|
db.createTask('explore', { target: { x: 100, y: 64, z: 100 } }, 5, 2);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(t2.setState).toHaveBeenCalled();
|
||||||
|
expect(t1.setState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip tasks with unknown task_type', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('unknown_type', {}, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(turtle.setState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not assign to busy turtles', () => {
|
||||||
|
const turtle = makeTurtle(1, { state: 'mining' });
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('explore', {}, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(db.assignTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not assign to disconnected turtles', () => {
|
||||||
|
const turtle = makeTurtle(1, { connected: false });
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('explore', {}, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(db.assignTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete task when turtle returns to idle', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('mine_area', { bounds: { minX: 0, minY: 0, minZ: 0, maxX: 5, maxY: 5, maxZ: 5 } }, 5, null);
|
||||||
|
|
||||||
|
// First tick: assigns
|
||||||
|
dispatcher._tick();
|
||||||
|
expect(turtle.stateName).toBe('mining');
|
||||||
|
|
||||||
|
// Simulate turtle completing
|
||||||
|
turtle.stateName = 'idle';
|
||||||
|
|
||||||
|
// Second tick: reconcile detects idle → complete
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(db.completeTask).toHaveBeenCalledWith(1);
|
||||||
|
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ type: 'task_completed', taskId: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-queue task when turtle disconnects', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('explore', {}, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
expect(turtle.stateName).toBe('exploring');
|
||||||
|
|
||||||
|
// Simulate disconnect
|
||||||
|
turtle.connected = false;
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(db.updateTaskStatus).toHaveBeenCalledWith(1, 'pending', 'Turtle disconnected');
|
||||||
|
expect(db.assignTask).toHaveBeenCalledWith(1, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pick closest turtle when multiple are available', () => {
|
||||||
|
const t1 = makeTurtle(1, { position: { x: 0, y: 64, z: 0 } });
|
||||||
|
const t2 = makeTurtle(2, { position: { x: 100, y: 64, z: 100 } });
|
||||||
|
turtles.set(1, t1);
|
||||||
|
turtles.set(2, t2);
|
||||||
|
|
||||||
|
db.createTask('transport', { target: { x: 95, y: 64, z: 95 } }, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
// t2 is closer to (95,64,95)
|
||||||
|
expect(t2.setState).toHaveBeenCalled();
|
||||||
|
expect(t1.setState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch when disabled', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('mine_area', {}, 5, null);
|
||||||
|
|
||||||
|
dispatcher.enabled = false;
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(turtle.setState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel a running task', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('mine_area', { bounds: {} }, 5, null);
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
dispatcher.cancelTask(1);
|
||||||
|
|
||||||
|
expect(turtle.stateName).toBe('idle');
|
||||||
|
expect(db.updateTaskStatus).toHaveBeenCalledWith(1, 'cancelled');
|
||||||
|
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ type: 'task_updated', taskId: 1, status: 'cancelled' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report status correctly', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
db.createTask('mine_area', { bounds: {} }, 5, null);
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
const status = dispatcher.status();
|
||||||
|
expect(status.enabled).toBe(true);
|
||||||
|
expect(status.activeTasks).toHaveLength(1);
|
||||||
|
expect(status.activeTasks[0].turtleId).toBe(1);
|
||||||
|
expect(status.activeTasks[0].taskId).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user