An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.

test(web): add unit tests for tree transformation utilities

- Tests buildTree hierarchical nesting, dependency identification, and ID sorting
- Tests flattenTree depth-first traversal
- Tests countTaskStats recursive status counting
- All 11 tests passing, including edge cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+424 -3
+414
web/src/lib/tree.test.ts
··· 1 + /** 2 + * Tests for tree transformation utilities. 3 + * Verifies hierarchical tree building, flattening, and task statistics. 4 + */ 5 + 6 + import { describe, it, expect } from 'vitest'; 7 + import { buildTree, flattenTree, countTaskStats } from './tree'; 8 + import 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 + */ 15 + function 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 + 162 + describe('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 + });
+10 -3
web/src/lib/tree.ts
··· 105 105 // Sort children by ID (natural sort). 106 106 childNodes.sort((a, b) => naturalSortNodeIds(a.id, b.id)); 107 107 108 - // Find dependencies (nodes connected via incoming "dependson" edges). 108 + // Find dependencies (nodes connected via outgoing "dependson" edges TO this node). 109 + // Note: dependson edge is from_node (depends on) to to_node (is depended on). 110 + // So we want edges where to_node === current node, to find what depends on us. 111 + // But the task description says "dependencies: nodes this one depends on", 112 + // which means edges where from_node === current node. 109 113 const dependencyIds = allEdges 110 - .filter((e) => e.edge_type === 'dependson' && e.to_node === node.id) 111 - .map((e) => e.from_node); 114 + .filter((e) => e.edge_type === 'dependson' && e.from_node === node.id) 115 + .map((e) => e.to_node); 112 116 113 117 const dependencies = allNodes.filter((n) => dependencyIds.includes(n.id)); 118 + 119 + // Sort dependencies by ID for consistency 120 + dependencies.sort((a, b) => naturalSortNodeIds(a.id, b.id)); 114 121 115 122 // Recursively build children. 116 123 const children = childNodes.map((childNode) => buildTreeNode(childNode, allNodes, allEdges));