An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
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});