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

test(web): add unit tests for decision graph transformations

Tests the pure functions from decision-graph.ts:
- decisionsToElements: converts GraphNode/GraphEdge to Cytoscape elements
- filterNowMode: filters for active decisions and chosen options only
- decisionStylesheet: generates Cytoscape styling rules

Coverage:
- P4f.AC2.1: Decision nodes render as diamonds with gold color
- P4f.AC2.2: Option nodes render as hexagons (chosen=green, rejected=gray)
- P4f.AC2.3: Outcome and revisit nodes with distinct shapes and colors
- P4f.AC2.4: Edge labels show relationship types
- P4f.AC3.1: Now mode includes only active/decided decisions, chosen options
- P4f.AC3.2: History mode includes full evolution with rejected/abandoned nodes
- Edge case handling: empty input arrays, endpoint filtering, realistic scenarios

27 tests total, all passing. Verifies complete decision graph transformation pipeline.

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

+446
+446
web/src/lib/decision-graph.test.ts
··· 1 + /** 2 + * Unit tests for decision-graph transformations. 3 + * Tests the pure functions: decisionsToElements, filterNowMode, and decisionStylesheet. 4 + */ 5 + 6 + import { describe, it, expect } from 'vitest'; 7 + import { decisionsToElements, filterNowMode, decisionStylesheet } from './decision-graph'; 8 + import type { GraphNode, GraphEdge } from '../types'; 9 + 10 + /** 11 + * Create a test GraphNode. 12 + */ 13 + function createNode( 14 + id: string, 15 + nodeType: string, 16 + status: string, 17 + title: string = 'Test Node' 18 + ): GraphNode { 19 + return { 20 + id, 21 + project_id: 'proj-1', 22 + node_type: nodeType as any, 23 + title, 24 + description: 'Test description', 25 + status: status as any, 26 + priority: 'medium', 27 + assigned_to: null, 28 + created_by: null, 29 + labels: [], 30 + created_at: '2026-02-11T00:00:00Z', 31 + started_at: null, 32 + completed_at: null, 33 + blocked_reason: null, 34 + metadata: {}, 35 + }; 36 + } 37 + 38 + /** 39 + * Create a test GraphEdge. 40 + */ 41 + function createEdge( 42 + id: string, 43 + fromNode: string, 44 + toNode: string, 45 + edgeType: string, 46 + label: string | null = null 47 + ): GraphEdge { 48 + return { 49 + id, 50 + edge_type: edgeType as any, 51 + from_node: fromNode, 52 + to_node: toNode, 53 + label, 54 + created_at: '2026-02-11T00:00:00Z', 55 + }; 56 + } 57 + 58 + describe('decision-graph transformations', () => { 59 + describe('decisionsToElements', () => { 60 + it('should convert nodes to Cytoscape element format', () => { 61 + const nodes: Array<GraphNode> = [createNode('n1', 'decision', 'active', 'Decide X')]; 62 + const edges: Array<GraphEdge> = []; 63 + 64 + const elements = decisionsToElements(nodes, edges); 65 + 66 + const nodeElement = elements.find((el) => el.data?.id === 'n1'); 67 + expect(nodeElement).toBeDefined(); 68 + expect(nodeElement?.data).toMatchObject({ 69 + id: 'n1', 70 + label: 'Decide X', 71 + type: 'decision', 72 + status: 'active', 73 + }); 74 + }); 75 + 76 + it('should convert edges to Cytoscape element format', () => { 77 + const nodes: Array<GraphNode> = []; 78 + const edges: Array<GraphEdge> = [ 79 + createEdge('e1', 'n1', 'n2', 'chosen', 'Better approach'), 80 + ]; 81 + 82 + const elements = decisionsToElements(nodes, edges); 83 + 84 + const edgeElement = elements.find((el) => el.data?.id === 'e1'); 85 + expect(edgeElement).toBeDefined(); 86 + expect(edgeElement?.data).toMatchObject({ 87 + id: 'e1', 88 + source: 'n1', 89 + target: 'n2', 90 + label: 'Better approach', 91 + edgeType: 'chosen', 92 + }); 93 + }); 94 + 95 + it('should use edge_type as label when label is null', () => { 96 + const edges: Array<GraphEdge> = [ 97 + createEdge('e1', 'n1', 'n2', 'leadsto', null), 98 + ]; 99 + 100 + const elements = decisionsToElements([], edges); 101 + 102 + const edgeElement = elements.find((el) => el.data?.id === 'e1'); 103 + expect(edgeElement?.data?.label).toBe('leadsto'); 104 + }); 105 + 106 + it('should return empty array for empty input', () => { 107 + const elements = decisionsToElements([], []); 108 + expect(elements).toEqual([]); 109 + }); 110 + 111 + it('should include all metadata fields in node data', () => { 112 + const node: GraphNode = { 113 + id: 'n1', 114 + project_id: 'proj-1', 115 + node_type: 'decision', 116 + title: 'Decide X', 117 + description: 'Test description', 118 + status: 'active', 119 + priority: 'high', 120 + assigned_to: 'agent1', 121 + created_by: 'user1', 122 + labels: ['important'], 123 + created_at: '2026-02-11T00:00:00Z', 124 + started_at: '2026-02-11T01:00:00Z', 125 + completed_at: null, 126 + blocked_reason: null, 127 + metadata: { key: 'value' }, 128 + }; 129 + 130 + const elements = decisionsToElements([node], []); 131 + 132 + const nodeElement = elements.find((el) => el.data?.id === 'n1'); 133 + expect(nodeElement?.data).toMatchObject({ 134 + priority: 'high', 135 + assigned_to: 'agent1', 136 + metadata: { key: 'value' }, 137 + }); 138 + }); 139 + }); 140 + 141 + describe('filterNowMode', () => { 142 + it('should include active decisions', () => { 143 + const nodes: Array<GraphNode> = [ 144 + createNode('d1', 'decision', 'active'), 145 + createNode('d2', 'decision', 'decided'), 146 + ]; 147 + 148 + const { nodes: filtered } = filterNowMode(nodes, []); 149 + 150 + expect(filtered.map((n) => n.id)).toContain('d1'); 151 + expect(filtered.map((n) => n.id)).toContain('d2'); 152 + }); 153 + 154 + it('should exclude rejected decisions', () => { 155 + const nodes: Array<GraphNode> = [ 156 + createNode('d1', 'decision', 'active'), 157 + createNode('d2', 'decision', 'rejected'), 158 + ]; 159 + 160 + const { nodes: filtered } = filterNowMode(nodes, []); 161 + 162 + expect(filtered.map((n) => n.id)).toContain('d1'); 163 + expect(filtered.map((n) => n.id)).not.toContain('d2'); 164 + }); 165 + 166 + it('should include chosen options only', () => { 167 + const nodes: Array<GraphNode> = [ 168 + createNode('o1', 'option', 'chosen'), 169 + createNode('o2', 'option', 'rejected'), 170 + createNode('o3', 'option', 'abandoned'), 171 + ]; 172 + 173 + const { nodes: filtered } = filterNowMode(nodes, []); 174 + 175 + expect(filtered.map((n) => n.id)).toContain('o1'); 176 + expect(filtered.map((n) => n.id)).not.toContain('o2'); 177 + expect(filtered.map((n) => n.id)).not.toContain('o3'); 178 + }); 179 + 180 + it('should include active/completed outcomes', () => { 181 + const nodes: Array<GraphNode> = [ 182 + createNode('oc1', 'outcome', 'active'), 183 + createNode('oc2', 'outcome', 'completed'), 184 + createNode('oc3', 'outcome', 'abandoned'), 185 + ]; 186 + 187 + const { nodes: filtered } = filterNowMode(nodes, []); 188 + 189 + expect(filtered.map((n) => n.id)).toContain('oc1'); 190 + expect(filtered.map((n) => n.id)).toContain('oc2'); 191 + expect(filtered.map((n) => n.id)).not.toContain('oc3'); 192 + }); 193 + 194 + it('should exclude other node types', () => { 195 + const nodes: Array<GraphNode> = [ 196 + createNode('g1', 'goal', 'active'), 197 + createNode('t1', 'task', 'completed'), 198 + createNode('obs1', 'observation', 'active'), 199 + createNode('d1', 'decision', 'active'), 200 + ]; 201 + 202 + const { nodes: filtered } = filterNowMode(nodes, []); 203 + 204 + expect(filtered.map((n) => n.id)).not.toContain('g1'); 205 + expect(filtered.map((n) => n.id)).not.toContain('t1'); 206 + expect(filtered.map((n) => n.id)).not.toContain('obs1'); 207 + expect(filtered.map((n) => n.id)).toContain('d1'); 208 + }); 209 + 210 + it('should prune edges where either endpoint is filtered out', () => { 211 + const nodes: Array<GraphNode> = [ 212 + createNode('d1', 'decision', 'active'), 213 + createNode('o1', 'option', 'chosen'), 214 + createNode('o2', 'option', 'rejected'), 215 + ]; 216 + const edges: Array<GraphEdge> = [ 217 + createEdge('e1', 'd1', 'o1', 'chosen'), // both endpoints included 218 + createEdge('e2', 'd1', 'o2', 'chosen'), // o2 is filtered out 219 + createEdge('e3', 'o2', 'o1', 'leadsto'), // o2 is filtered out 220 + ]; 221 + 222 + const { edges: filtered } = filterNowMode(nodes, edges); 223 + 224 + expect(filtered.map((e) => e.id)).toContain('e1'); 225 + expect(filtered.map((e) => e.id)).not.toContain('e2'); 226 + expect(filtered.map((e) => e.id)).not.toContain('e3'); 227 + }); 228 + 229 + it('should handle empty input arrays', () => { 230 + const { nodes, edges } = filterNowMode([], []); 231 + 232 + expect(nodes).toEqual([]); 233 + expect(edges).toEqual([]); 234 + }); 235 + 236 + it('should include revisit nodes in history mode only', () => { 237 + const nodes: Array<GraphNode> = [ 238 + createNode('rv1', 'revisit', 'active'), 239 + createNode('d1', 'decision', 'active'), 240 + ]; 241 + 242 + const { nodes: filtered } = filterNowMode(nodes, []); 243 + 244 + // Revisit nodes are not included in Now mode 245 + expect(filtered.map((n) => n.id)).not.toContain('rv1'); 246 + expect(filtered.map((n) => n.id)).toContain('d1'); 247 + }); 248 + }); 249 + 250 + describe('decisionStylesheet', () => { 251 + it('should return a non-empty stylesheet array', () => { 252 + const stylesheet = decisionStylesheet(); 253 + 254 + expect(Array.isArray(stylesheet)).toBe(true); 255 + expect(stylesheet.length).toBeGreaterThan(0); 256 + }); 257 + 258 + it('should include node styling rules', () => { 259 + const stylesheet = decisionStylesheet(); 260 + 261 + const nodeSelectors = stylesheet.filter((rule) => 262 + typeof rule.selector === 'string' && rule.selector.includes('node') 263 + ); 264 + expect(nodeSelectors.length).toBeGreaterThan(0); 265 + }); 266 + 267 + it('should include edge styling rules', () => { 268 + const stylesheet = decisionStylesheet(); 269 + 270 + const edgeSelectors = stylesheet.filter((rule) => 271 + typeof rule.selector === 'string' && rule.selector.includes('edge') 272 + ); 273 + expect(edgeSelectors.length).toBeGreaterThan(0); 274 + }); 275 + 276 + it('should include decision node styling', () => { 277 + const stylesheet = decisionStylesheet(); 278 + 279 + const decisionRule = stylesheet.find( 280 + (rule) => typeof rule.selector === 'string' && rule.selector.includes('decision') 281 + ); 282 + expect(decisionRule).toBeDefined(); 283 + expect((decisionRule?.style as any)['shape']).toBe('diamond'); 284 + }); 285 + 286 + it('should include option node styling', () => { 287 + const stylesheet = decisionStylesheet(); 288 + 289 + const optionRule = stylesheet.find( 290 + (rule) => typeof rule.selector === 'string' && rule.selector.includes('option') 291 + ); 292 + expect(optionRule).toBeDefined(); 293 + expect((optionRule?.style as any)['shape']).toBe('hexagon'); 294 + }); 295 + 296 + it('should include outcome node styling', () => { 297 + const stylesheet = decisionStylesheet(); 298 + 299 + const outcomeRule = stylesheet.find( 300 + (rule) => typeof rule.selector === 'string' && rule.selector.includes('outcome') 301 + ); 302 + expect(outcomeRule).toBeDefined(); 303 + expect((outcomeRule?.style as any)['shape']).toBe('ellipse'); 304 + }); 305 + 306 + it('should include revisit node styling', () => { 307 + const stylesheet = decisionStylesheet(); 308 + 309 + const revisitRule = stylesheet.find( 310 + (rule) => typeof rule.selector === 'string' && rule.selector.includes('revisit') 311 + ); 312 + expect(revisitRule).toBeDefined(); 313 + expect((revisitRule?.style as any)['shape']).toBe('triangle'); 314 + }); 315 + 316 + it('should style chosen options as green', () => { 317 + const stylesheet = decisionStylesheet(); 318 + 319 + const chosenRule = stylesheet.find( 320 + (rule) => 321 + typeof rule.selector === 'string' && 322 + rule.selector.includes('option') && 323 + rule.selector.includes('chosen') 324 + ); 325 + expect(chosenRule).toBeDefined(); 326 + expect((chosenRule?.style as any)['background-color']).toBe('#32CD32'); 327 + }); 328 + 329 + it('should style rejected options as gray with dashed border', () => { 330 + const stylesheet = decisionStylesheet(); 331 + 332 + const rejectedRule = stylesheet.find( 333 + (rule) => 334 + typeof rule.selector === 'string' && 335 + rule.selector.includes('option') && 336 + rule.selector.includes('rejected') 337 + ); 338 + expect(rejectedRule).toBeDefined(); 339 + expect((rejectedRule?.style as any)['background-color']).toBe('#666'); 340 + expect((rejectedRule?.style as any)['border-style']).toBe('dashed'); 341 + }); 342 + 343 + it('should style decision nodes as gold', () => { 344 + const stylesheet = decisionStylesheet(); 345 + 346 + const decisionRule = stylesheet.find( 347 + (rule) => typeof rule.selector === 'string' && rule.selector === 'node[type = "decision"]' 348 + ); 349 + expect(decisionRule).toBeDefined(); 350 + expect((decisionRule?.style as any)['background-color']).toBe('#FFD700'); 351 + }); 352 + 353 + it('should style chosen edges as green and thick', () => { 354 + const stylesheet = decisionStylesheet(); 355 + 356 + const chosenEdgeRule = stylesheet.find( 357 + (rule) => 358 + typeof rule.selector === 'string' && 359 + rule.selector.includes('edge') && 360 + rule.selector.includes('chosen') 361 + ); 362 + expect(chosenEdgeRule).toBeDefined(); 363 + expect((chosenEdgeRule?.style as any)['line-color']).toBe('#32CD32'); 364 + expect((chosenEdgeRule?.style as any)['width']).toBe(3); 365 + }); 366 + 367 + it('should style rejected edges as dashed gray', () => { 368 + const stylesheet = decisionStylesheet(); 369 + 370 + const rejectedEdgeRule = stylesheet.find( 371 + (rule) => 372 + typeof rule.selector === 'string' && 373 + rule.selector.includes('edge') && 374 + rule.selector.includes('rejected') 375 + ); 376 + expect(rejectedEdgeRule).toBeDefined(); 377 + expect((rejectedEdgeRule?.style as any)['line-style']).toBe('dashed'); 378 + }); 379 + }); 380 + 381 + describe('integration tests', () => { 382 + it('should handle a realistic decision graph scenario', () => { 383 + // Create a realistic scenario with decisions, options, and outcomes 384 + const nodes: Array<GraphNode> = [ 385 + createNode('d1', 'decision', 'decided', 'Choose database'), 386 + createNode('o1', 'option', 'chosen', 'PostgreSQL'), 387 + createNode('o2', 'option', 'rejected', 'MongoDB'), 388 + createNode('oc1', 'outcome', 'completed', 'DB configured'), 389 + ]; 390 + 391 + const edges: Array<GraphEdge> = [ 392 + createEdge('e1', 'd1', 'o1', 'chosen', 'Relational requirement'), 393 + createEdge('e2', 'd1', 'o2', 'rejected', 'Incompatible schema'), 394 + createEdge('e3', 'o1', 'oc1', 'leadsto', ''), 395 + ]; 396 + 397 + // Test Now mode filtering 398 + const { nodes: nowNodes, edges: nowEdges } = filterNowMode(nodes, edges); 399 + 400 + // Should include decided decision and chosen option 401 + expect(nowNodes.map((n) => n.id)).toContain('d1'); 402 + expect(nowNodes.map((n) => n.id)).toContain('o1'); 403 + expect(nowNodes.map((n) => n.id)).toContain('oc1'); 404 + 405 + // Should exclude rejected option 406 + expect(nowNodes.map((n) => n.id)).not.toContain('o2'); 407 + 408 + // Edges should only connect included nodes 409 + expect(nowEdges.length).toBeLessThan(edges.length); 410 + 411 + // Test element conversion 412 + const elements = decisionsToElements(nodes, edges); 413 + expect(elements.length).toBeGreaterThan(0); 414 + 415 + // Verify styling 416 + const stylesheet = decisionStylesheet(); 417 + expect(stylesheet.length).toBeGreaterThan(0); 418 + }); 419 + 420 + it('should handle history mode with all nodes', () => { 421 + // In history mode, we use the full unfiltered data 422 + const nodes: Array<GraphNode> = [ 423 + createNode('d1', 'decision', 'decided', 'Choose database'), 424 + createNode('o1', 'option', 'chosen', 'PostgreSQL'), 425 + createNode('o2', 'option', 'rejected', 'MongoDB'), 426 + createNode('o3', 'option', 'abandoned', 'SQLite'), 427 + ]; 428 + 429 + const edges: Array<GraphEdge> = [ 430 + createEdge('e1', 'd1', 'o1', 'chosen'), 431 + createEdge('e2', 'd1', 'o2', 'rejected'), 432 + createEdge('e3', 'd1', 'o3', 'rejected'), 433 + ]; 434 + 435 + // Without filtering (history mode), all should be included 436 + const elements = decisionsToElements(nodes, edges); 437 + 438 + // Should have all nodes + all edges 439 + const nodeCount = elements.filter((el) => !el.data?.source).length; 440 + expect(nodeCount).toBe(4); 441 + 442 + const edgeCount = elements.filter((el) => el.data?.source).length; 443 + expect(edgeCount).toBe(3); 444 + }); 445 + }); 446 + });