feat: add TaskDispatcher tests for task assignment and management

This commit is contained in:
MayaTheShy
2026-03-22 11:47:22 -04:00
parent b6ab6f94f6
commit 56fc79f5f2

View 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);
});
});