An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at new-directions 414 lines 12 kB view raw
1/** 2 * Tests for tree transformation utilities. 3 * Verifies hierarchical tree building, flattening, and task statistics. 4 */ 5 6import { describe, it, expect } from 'vitest'; 7import { buildTree, flattenTree, countTaskStats } from './tree'; 8import type { GraphNode, GraphEdge, GoalTree } from '../types'; 9 10/** 11 * Create test fixtures: a goal with 2 tasks (one completed, one in_progress), 12 * and a subtask under the first task. Includes Contains edges for hierarchy 13 * and a dependson edge for dependencies. 14 */ 15function createTestFixture(): GoalTree { 16 const nodes: Array<GraphNode> = [ 17 { 18 id: 'ra-1234', 19 project_id: 'proj-1', 20 node_type: 'goal', 21 title: 'Main Goal', 22 description: 'Complete the main objective', 23 status: 'active', 24 priority: 'high', 25 assigned_to: null, 26 created_by: 'user-1', 27 labels: [], 28 created_at: '2026-02-01T00:00:00Z', 29 started_at: '2026-02-02T00:00:00Z', 30 completed_at: null, 31 blocked_reason: null, 32 metadata: {}, 33 }, 34 { 35 id: 'ra-1234.1', 36 project_id: 'proj-1', 37 node_type: 'task', 38 title: 'Task 1 (Completed)', 39 description: 'First task that is completed', 40 status: 'completed', 41 priority: 'high', 42 assigned_to: 'agent-1', 43 created_by: 'user-1', 44 labels: [], 45 created_at: '2026-02-01T01:00:00Z', 46 started_at: '2026-02-02T01:00:00Z', 47 completed_at: '2026-02-05T01:00:00Z', 48 blocked_reason: null, 49 metadata: {}, 50 }, 51 { 52 id: 'ra-1234.2', 53 project_id: 'proj-1', 54 node_type: 'task', 55 title: 'Task 2 (In Progress)', 56 description: 'Second task that is in progress', 57 status: 'in_progress', 58 priority: 'medium', 59 assigned_to: 'agent-2', 60 created_by: 'user-1', 61 labels: [], 62 created_at: '2026-02-03T00:00:00Z', 63 started_at: '2026-02-04T00:00:00Z', 64 completed_at: null, 65 blocked_reason: null, 66 metadata: {}, 67 }, 68 { 69 id: 'ra-1234.1.1', 70 project_id: 'proj-1', 71 node_type: 'task', 72 title: 'Subtask of Task 1', 73 description: 'A subtask under the first task', 74 status: 'completed', 75 priority: 'low', 76 assigned_to: null, 77 created_by: 'user-1', 78 labels: [], 79 created_at: '2026-02-01T02:00:00Z', 80 started_at: null, 81 completed_at: '2026-02-05T02:00:00Z', 82 blocked_reason: null, 83 metadata: {}, 84 }, 85 { 86 id: 'ra-1234.3', 87 project_id: 'proj-1', 88 node_type: 'task', 89 title: 'Task 3 (Ready)', 90 description: 'Third task ready to start', 91 status: 'ready', 92 priority: null, 93 assigned_to: null, 94 created_by: 'user-1', 95 labels: [], 96 created_at: '2026-02-06T00:00:00Z', 97 started_at: null, 98 completed_at: null, 99 blocked_reason: null, 100 metadata: {}, 101 }, 102 ]; 103 104 const edges: Array<GraphEdge> = [ 105 // Hierarchy: goal contains tasks 106 { 107 id: 'e-1', 108 edge_type: 'contains', 109 from_node: 'ra-1234', 110 to_node: 'ra-1234.1', 111 label: null, 112 created_at: '2026-02-01T00:00:00Z', 113 }, 114 { 115 id: 'e-2', 116 edge_type: 'contains', 117 from_node: 'ra-1234', 118 to_node: 'ra-1234.2', 119 label: null, 120 created_at: '2026-02-01T00:00:00Z', 121 }, 122 { 123 id: 'e-3', 124 edge_type: 'contains', 125 from_node: 'ra-1234', 126 to_node: 'ra-1234.3', 127 label: null, 128 created_at: '2026-02-01T00:00:00Z', 129 }, 130 // Hierarchy: task 1 contains subtask 131 { 132 id: 'e-4', 133 edge_type: 'contains', 134 from_node: 'ra-1234.1', 135 to_node: 'ra-1234.1.1', 136 label: null, 137 created_at: '2026-02-01T02:00:00Z', 138 }, 139 // Dependency: task 2 depends on task 1 140 { 141 id: 'e-5', 142 edge_type: 'dependson', 143 from_node: 'ra-1234.2', 144 to_node: 'ra-1234.1', 145 label: null, 146 created_at: '2026-02-03T00:00:00Z', 147 }, 148 // Dependency: task 3 depends on task 2 149 { 150 id: 'e-6', 151 edge_type: 'dependson', 152 from_node: 'ra-1234.3', 153 to_node: 'ra-1234.2', 154 label: null, 155 created_at: '2026-02-06T00:00:00Z', 156 }, 157 ]; 158 159 return { nodes, edges }; 160} 161 162describe('tree transformation utilities', () => { 163 describe('buildTree', () => { 164 it('builds correct nesting with root as goal and tasks as children', () => { 165 const fixture = createTestFixture(); 166 const tree = buildTree(fixture); 167 168 // Root should be the goal 169 expect(tree.node.id).toBe('ra-1234'); 170 expect(tree.node.node_type).toBe('goal'); 171 172 // Should have 3 direct children (tasks) 173 expect(tree.children.length).toBe(3); 174 175 // Children should be the tasks, sorted by ID 176 expect(tree.children[0]!.node.id).toBe('ra-1234.1'); 177 expect(tree.children[1]!.node.id).toBe('ra-1234.2'); 178 expect(tree.children[2]!.node.id).toBe('ra-1234.3'); 179 }); 180 181 it('nests subtasks under their parent tasks', () => { 182 const fixture = createTestFixture(); 183 const tree = buildTree(fixture); 184 185 // Task 1 should have 1 child (the subtask) 186 const task1 = tree.children[0]; 187 expect(task1!.children.length).toBe(1); 188 expect(task1!.children[0]!.node.id).toBe('ra-1234.1.1'); 189 expect(task1!.children[0]!.node.title).toBe('Subtask of Task 1'); 190 }); 191 192 it('correctly identifies dependencies from dependson edges', () => { 193 const fixture = createTestFixture(); 194 const tree = buildTree(fixture); 195 196 // Task 2 should have Task 1 as a dependency 197 const task2 = tree.children[1]; 198 expect(task2!.dependencies.length).toBe(1); 199 expect(task2!.dependencies[0]!.id).toBe('ra-1234.1'); 200 201 // Task 3 should have Task 2 as a dependency 202 const task3 = tree.children[2]; 203 expect(task3!.dependencies.length).toBe(1); 204 expect(task3!.dependencies[0]!.id).toBe('ra-1234.2'); 205 206 // Task 1 should have no dependencies 207 const task1 = tree.children[0]; 208 expect(task1!.dependencies.length).toBe(0); 209 }); 210 211 it('sorts children by ID (natural sort)', () => { 212 const nodes: Array<GraphNode> = [ 213 { 214 id: 'ra-1000', 215 project_id: 'proj-1', 216 node_type: 'goal', 217 title: 'Goal', 218 description: 'Goal', 219 status: 'active', 220 priority: null, 221 assigned_to: null, 222 created_by: null, 223 labels: [], 224 created_at: '2026-02-01T00:00:00Z', 225 started_at: null, 226 completed_at: null, 227 blocked_reason: null, 228 metadata: {}, 229 }, 230 { 231 id: 'ra-1000.10', 232 project_id: 'proj-1', 233 node_type: 'task', 234 title: 'Task 10', 235 description: '', 236 status: 'pending', 237 priority: null, 238 assigned_to: null, 239 created_by: null, 240 labels: [], 241 created_at: '2026-02-01T00:00:00Z', 242 started_at: null, 243 completed_at: null, 244 blocked_reason: null, 245 metadata: {}, 246 }, 247 { 248 id: 'ra-1000.2', 249 project_id: 'proj-1', 250 node_type: 'task', 251 title: 'Task 2', 252 description: '', 253 status: 'pending', 254 priority: null, 255 assigned_to: null, 256 created_by: null, 257 labels: [], 258 created_at: '2026-02-01T00:00:00Z', 259 started_at: null, 260 completed_at: null, 261 blocked_reason: null, 262 metadata: {}, 263 }, 264 { 265 id: 'ra-1000.1', 266 project_id: 'proj-1', 267 node_type: 'task', 268 title: 'Task 1', 269 description: '', 270 status: 'pending', 271 priority: null, 272 assigned_to: null, 273 created_by: null, 274 labels: [], 275 created_at: '2026-02-01T00:00:00Z', 276 started_at: null, 277 completed_at: null, 278 blocked_reason: null, 279 metadata: {}, 280 }, 281 ]; 282 283 const edges: Array<GraphEdge> = [ 284 { 285 id: 'e-1', 286 edge_type: 'contains', 287 from_node: 'ra-1000', 288 to_node: 'ra-1000.10', 289 label: null, 290 created_at: '2026-02-01T00:00:00Z', 291 }, 292 { 293 id: 'e-2', 294 edge_type: 'contains', 295 from_node: 'ra-1000', 296 to_node: 'ra-1000.2', 297 label: null, 298 created_at: '2026-02-01T00:00:00Z', 299 }, 300 { 301 id: 'e-3', 302 edge_type: 'contains', 303 from_node: 'ra-1000', 304 to_node: 'ra-1000.1', 305 label: null, 306 created_at: '2026-02-01T00:00:00Z', 307 }, 308 ]; 309 310 const tree = buildTree({ nodes, edges }); 311 312 // Children should be sorted: .1, .2, .10 313 expect(tree.children[0]!.node.id).toBe('ra-1000.1'); 314 expect(tree.children[1]!.node.id).toBe('ra-1000.2'); 315 expect(tree.children[2]!.node.id).toBe('ra-1000.10'); 316 }); 317 318 it('initializes all TreeNodes with expanded = true', () => { 319 const fixture = createTestFixture(); 320 const tree = buildTree(fixture); 321 322 expect(tree.expanded).toBe(true); 323 tree.children.forEach((child) => { 324 expect(child.expanded).toBe(true); 325 }); 326 }); 327 328 it('throws error when nodes array is empty', () => { 329 const emptyGoalTree: GoalTree = { nodes: [], edges: [] }; 330 expect(() => buildTree(emptyGoalTree)).toThrow('Cannot build tree: no nodes provided'); 331 }); 332 }); 333 334 describe('flattenTree', () => { 335 it('returns nodes in depth-first order', () => { 336 const fixture = createTestFixture(); 337 const tree = buildTree(fixture); 338 const flattened = flattenTree(tree); 339 340 // Depth-first order: root, then task1 and its subtask, then task2, then task3 341 expect(flattened.length).toBeGreaterThan(0); 342 expect(flattened[0]!.node.id).toBe('ra-1234'); // root first 343 expect(flattened[1]!.node.id).toBe('ra-1234.1'); // task 1 344 expect(flattened[2]!.node.id).toBe('ra-1234.1.1'); // subtask (nested under task 1) 345 expect(flattened[3]!.node.id).toBe('ra-1234.2'); // task 2 346 expect(flattened[4]!.node.id).toBe('ra-1234.3'); // task 3 347 }); 348 349 it('includes all nodes from the tree', () => { 350 const fixture = createTestFixture(); 351 const tree = buildTree(fixture); 352 const flattened = flattenTree(tree); 353 354 // 1 goal + 3 tasks + 1 subtask = 5 nodes 355 expect(flattened.length).toBe(5); 356 }); 357 }); 358 359 describe('countTaskStats', () => { 360 it('counts tasks by status correctly', () => { 361 const fixture = createTestFixture(); 362 const tree = buildTree(fixture); 363 const stats = countTaskStats(tree); 364 365 // Fixture has: 1 completed (task 1 and subtask), 1 in_progress (task 2), 1 ready (task 3) 366 // Total should count all 4 tasks (not the goal) 367 expect(stats.total).toBe(4); 368 expect(stats.completed).toBe(2); // task 1 and subtask 369 expect(stats.inProgress).toBe(1); // task 2 370 expect(stats.ready).toBe(1); // task 3 371 expect(stats.blocked).toBe(0); 372 }); 373 374 it('does not count non-task nodes', () => { 375 const fixture = createTestFixture(); 376 const tree = buildTree(fixture); 377 const stats = countTaskStats(tree); 378 379 // Should count only task-type nodes, not the goal node 380 expect(stats.total).toBe(4); // not 5 (excludes the goal) 381 }); 382 383 it('handles empty tree with only goal node', () => { 384 const nodes: Array<GraphNode> = [ 385 { 386 id: 'ra-5000', 387 project_id: 'proj-1', 388 node_type: 'goal', 389 title: 'Lonely Goal', 390 description: 'A goal with no tasks', 391 status: 'active', 392 priority: null, 393 assigned_to: null, 394 created_by: null, 395 labels: [], 396 created_at: '2026-02-01T00:00:00Z', 397 started_at: null, 398 completed_at: null, 399 blocked_reason: null, 400 metadata: {}, 401 }, 402 ]; 403 404 const tree = buildTree({ nodes, edges: [] }); 405 const stats = countTaskStats(tree); 406 407 expect(stats.total).toBe(0); 408 expect(stats.completed).toBe(0); 409 expect(stats.inProgress).toBe(0); 410 expect(stats.blocked).toBe(0); 411 expect(stats.ready).toBe(0); 412 }); 413 }); 414});