From 56fc79f5f2e6b570494a007f8d195ddbc898040a Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 22 Mar 2026 11:47:22 -0400 Subject: [PATCH] feat: add TaskDispatcher tests for task assignment and management --- server/__tests__/TaskDispatcher.test.js | 222 ++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 server/__tests__/TaskDispatcher.test.js diff --git a/server/__tests__/TaskDispatcher.test.js b/server/__tests__/TaskDispatcher.test.js new file mode 100644 index 0000000..bb9f648 --- /dev/null +++ b/server/__tests__/TaskDispatcher.test.js @@ -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); + }); +});