ai-workflows 2.1.1 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +165 -3
- package/CHANGELOG.md +10 -1
- package/LICENSE +21 -0
- package/README.md +303 -184
- package/dist/barrier.d.ts +153 -0
- package/dist/barrier.d.ts.map +1 -0
- package/dist/barrier.js +339 -0
- package/dist/barrier.js.map +1 -0
- package/dist/cascade-context.d.ts +149 -0
- package/dist/cascade-context.d.ts.map +1 -0
- package/dist/cascade-context.js +324 -0
- package/dist/cascade-context.js.map +1 -0
- package/dist/cascade-executor.d.ts +196 -0
- package/dist/cascade-executor.d.ts.map +1 -0
- package/dist/cascade-executor.js +384 -0
- package/dist/cascade-executor.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +4 -1
- package/dist/context.js.map +1 -1
- package/dist/dependency-graph.d.ts +157 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/dependency-graph.js +382 -0
- package/dist/dependency-graph.js.map +1 -0
- package/dist/every.d.ts +31 -2
- package/dist/every.d.ts.map +1 -1
- package/dist/every.js +63 -32
- package/dist/every.js.map +1 -1
- package/dist/graph/index.d.ts +8 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +8 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/topological-sort.d.ts +121 -0
- package/dist/graph/topological-sort.d.ts.map +1 -0
- package/dist/graph/topological-sort.js +292 -0
- package/dist/graph/topological-sort.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/on.d.ts +35 -10
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +52 -18
- package/dist/on.js.map +1 -1
- package/dist/timer-registry.d.ts +52 -0
- package/dist/timer-registry.d.ts.map +1 -0
- package/dist/timer-registry.js +120 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -1
- package/dist/types.js.map +1 -1
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +15 -11
- package/dist/workflow.js.map +1 -1
- package/package.json +11 -11
- package/src/barrier.ts +466 -0
- package/src/cascade-context.ts +488 -0
- package/src/cascade-executor.ts +587 -0
- package/src/context.ts +12 -7
- package/src/dependency-graph.ts +518 -0
- package/src/every.ts +104 -35
- package/src/graph/index.ts +19 -0
- package/src/graph/topological-sort.ts +414 -0
- package/src/index.ts +78 -0
- package/src/on.ts +81 -25
- package/src/timer-registry.ts +145 -0
- package/src/types.ts +121 -0
- package/src/workflow.ts +23 -16
- package/test/barrier-join.test.ts +434 -0
- package/test/barrier-unhandled-rejections.test.ts +359 -0
- package/test/cascade-context.test.ts +390 -0
- package/test/cascade-executor.test.ts +859 -0
- package/test/dependency-graph.test.ts +512 -0
- package/test/graph/topological-sort.test.ts +586 -0
- package/test/schedule-timer-cleanup.test.ts +344 -0
- package/test/send-race-conditions.test.ts +410 -0
- package/test/type-safety-every.test.ts +303 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for DependencyGraph architecture
|
|
3
|
+
*
|
|
4
|
+
* TDD RED Phase: These tests define the expected behavior for workflow step dependencies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
8
|
+
import {
|
|
9
|
+
DependencyGraph,
|
|
10
|
+
CircularDependencyError,
|
|
11
|
+
MissingDependencyError,
|
|
12
|
+
type DependencyConfig,
|
|
13
|
+
type DependencyType,
|
|
14
|
+
type GraphNode,
|
|
15
|
+
type ParallelGroup,
|
|
16
|
+
} from '../src/dependency-graph.js'
|
|
17
|
+
|
|
18
|
+
describe('DependencyGraph', () => {
|
|
19
|
+
let graph: DependencyGraph
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
graph = new DependencyGraph()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('DependencyConfig interface', () => {
|
|
26
|
+
it('should support dependsOn as single string', () => {
|
|
27
|
+
const config: DependencyConfig = {
|
|
28
|
+
dependsOn: 'Step1.complete',
|
|
29
|
+
}
|
|
30
|
+
expect(config.dependsOn).toBe('Step1.complete')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should support dependsOn as array of strings', () => {
|
|
34
|
+
const config: DependencyConfig = {
|
|
35
|
+
dependsOn: ['Step1.complete', 'Step2.complete'],
|
|
36
|
+
}
|
|
37
|
+
expect(config.dependsOn).toEqual(['Step1.complete', 'Step2.complete'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should support hard vs soft dependency types', () => {
|
|
41
|
+
const hardDep: DependencyConfig = {
|
|
42
|
+
dependsOn: 'Step1.complete',
|
|
43
|
+
type: 'hard', // Must complete successfully
|
|
44
|
+
}
|
|
45
|
+
const softDep: DependencyConfig = {
|
|
46
|
+
dependsOn: 'Step1.complete',
|
|
47
|
+
type: 'soft', // Can proceed even if dependency fails
|
|
48
|
+
}
|
|
49
|
+
expect(hardDep.type).toBe('hard')
|
|
50
|
+
expect(softDep.type).toBe('soft')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should default dependency type to hard', () => {
|
|
54
|
+
const config: DependencyConfig = {
|
|
55
|
+
dependsOn: 'Step1.complete',
|
|
56
|
+
}
|
|
57
|
+
// Implementation should default to 'hard' when type is not specified
|
|
58
|
+
expect(config.type).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('addNode()', () => {
|
|
63
|
+
it('should add a node without dependencies', () => {
|
|
64
|
+
graph.addNode('Step1.complete')
|
|
65
|
+
|
|
66
|
+
const nodes = graph.getNodes()
|
|
67
|
+
expect(nodes).toHaveLength(1)
|
|
68
|
+
expect(nodes[0]?.id).toBe('Step1.complete')
|
|
69
|
+
expect(nodes[0]?.dependencies).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should add a node with single dependency', () => {
|
|
73
|
+
graph.addNode('Step1.complete')
|
|
74
|
+
graph.addNode('Step2.complete', { dependsOn: 'Step1.complete' })
|
|
75
|
+
|
|
76
|
+
const step2 = graph.getNode('Step2.complete')
|
|
77
|
+
expect(step2?.dependencies).toContain('Step1.complete')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should add a node with multiple dependencies', () => {
|
|
81
|
+
graph.addNode('Step1.complete')
|
|
82
|
+
graph.addNode('Step2.complete')
|
|
83
|
+
graph.addNode('Step3.complete', {
|
|
84
|
+
dependsOn: ['Step1.complete', 'Step2.complete'],
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const step3 = graph.getNode('Step3.complete')
|
|
88
|
+
expect(step3?.dependencies).toEqual(['Step1.complete', 'Step2.complete'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should throw MissingDependencyError for non-existent dependency', () => {
|
|
92
|
+
expect(() => {
|
|
93
|
+
graph.addNode('Step2.complete', { dependsOn: 'NonExistent.complete' })
|
|
94
|
+
}).toThrow(MissingDependencyError)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should throw on self-reference', () => {
|
|
98
|
+
expect(() => {
|
|
99
|
+
graph.addNode('Step1.complete', { dependsOn: 'Step1.complete' })
|
|
100
|
+
}).toThrow(CircularDependencyError)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should store dependency type (hard/soft)', () => {
|
|
104
|
+
graph.addNode('Step1.complete')
|
|
105
|
+
graph.addNode('Step2.complete', {
|
|
106
|
+
dependsOn: 'Step1.complete',
|
|
107
|
+
type: 'soft',
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const step2 = graph.getNode('Step2.complete')
|
|
111
|
+
expect(step2?.dependencyTypes?.['Step1.complete']).toBe('soft')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('addEdge()', () => {
|
|
116
|
+
it('should add an edge between existing nodes', () => {
|
|
117
|
+
graph.addNode('Step1.complete')
|
|
118
|
+
graph.addNode('Step2.complete')
|
|
119
|
+
graph.addEdge('Step1.complete', 'Step2.complete')
|
|
120
|
+
|
|
121
|
+
const step2 = graph.getNode('Step2.complete')
|
|
122
|
+
expect(step2?.dependencies).toContain('Step1.complete')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should throw on non-existent source node', () => {
|
|
126
|
+
graph.addNode('Step2.complete')
|
|
127
|
+
expect(() => {
|
|
128
|
+
graph.addEdge('NonExistent.complete', 'Step2.complete')
|
|
129
|
+
}).toThrow(MissingDependencyError)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should throw on non-existent target node', () => {
|
|
133
|
+
graph.addNode('Step1.complete')
|
|
134
|
+
expect(() => {
|
|
135
|
+
graph.addEdge('Step1.complete', 'NonExistent.complete')
|
|
136
|
+
}).toThrow(MissingDependencyError)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should support specifying dependency type on edge', () => {
|
|
140
|
+
graph.addNode('Step1.complete')
|
|
141
|
+
graph.addNode('Step2.complete')
|
|
142
|
+
graph.addEdge('Step1.complete', 'Step2.complete', 'soft')
|
|
143
|
+
|
|
144
|
+
const step2 = graph.getNode('Step2.complete')
|
|
145
|
+
expect(step2?.dependencyTypes?.['Step1.complete']).toBe('soft')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('detectCycles()', () => {
|
|
150
|
+
it('should return null for acyclic graph', () => {
|
|
151
|
+
graph.addNode('Step1.complete')
|
|
152
|
+
graph.addNode('Step2.complete', { dependsOn: 'Step1.complete' })
|
|
153
|
+
graph.addNode('Step3.complete', { dependsOn: 'Step2.complete' })
|
|
154
|
+
|
|
155
|
+
expect(graph.detectCycles()).toBeNull()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should detect direct cycle (A -> B -> A)', () => {
|
|
159
|
+
graph.addNode('StepA.complete')
|
|
160
|
+
graph.addNode('StepB.complete', { dependsOn: 'StepA.complete' })
|
|
161
|
+
|
|
162
|
+
// Adding this edge would create a cycle
|
|
163
|
+
expect(() => {
|
|
164
|
+
graph.addEdge('StepB.complete', 'StepA.complete')
|
|
165
|
+
}).toThrow(CircularDependencyError)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should detect indirect cycle (A -> B -> C -> A)', () => {
|
|
169
|
+
graph.addNode('StepA.complete')
|
|
170
|
+
graph.addNode('StepB.complete', { dependsOn: 'StepA.complete' })
|
|
171
|
+
graph.addNode('StepC.complete', { dependsOn: 'StepB.complete' })
|
|
172
|
+
|
|
173
|
+
expect(() => {
|
|
174
|
+
graph.addEdge('StepC.complete', 'StepA.complete')
|
|
175
|
+
}).toThrow(CircularDependencyError)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('should include cycle path in error message', () => {
|
|
179
|
+
graph.addNode('StepA.complete')
|
|
180
|
+
graph.addNode('StepB.complete', { dependsOn: 'StepA.complete' })
|
|
181
|
+
graph.addNode('StepC.complete', { dependsOn: 'StepB.complete' })
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
graph.addEdge('StepC.complete', 'StepA.complete')
|
|
185
|
+
expect.fail('Should have thrown CircularDependencyError')
|
|
186
|
+
} catch (error) {
|
|
187
|
+
expect(error).toBeInstanceOf(CircularDependencyError)
|
|
188
|
+
const cycleError = error as CircularDependencyError
|
|
189
|
+
expect(cycleError.cyclePath).toBeDefined()
|
|
190
|
+
expect(cycleError.cyclePath).toContain('StepA.complete')
|
|
191
|
+
expect(cycleError.cyclePath).toContain('StepB.complete')
|
|
192
|
+
expect(cycleError.cyclePath).toContain('StepC.complete')
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('should handle diamond dependencies without false positive', () => {
|
|
197
|
+
// Diamond pattern: A -> B, A -> C, B -> D, C -> D
|
|
198
|
+
// This is NOT a cycle
|
|
199
|
+
graph.addNode('StepA.complete')
|
|
200
|
+
graph.addNode('StepB.complete', { dependsOn: 'StepA.complete' })
|
|
201
|
+
graph.addNode('StepC.complete', { dependsOn: 'StepA.complete' })
|
|
202
|
+
graph.addNode('StepD.complete', {
|
|
203
|
+
dependsOn: ['StepB.complete', 'StepC.complete'],
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
expect(graph.detectCycles()).toBeNull()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('getParallelGroups()', () => {
|
|
211
|
+
it('should identify single node as one group', () => {
|
|
212
|
+
graph.addNode('Step1.complete')
|
|
213
|
+
|
|
214
|
+
const groups = graph.getParallelGroups()
|
|
215
|
+
expect(groups).toHaveLength(1)
|
|
216
|
+
expect(groups[0]?.nodes).toContain('Step1.complete')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('should group independent nodes together', () => {
|
|
220
|
+
graph.addNode('Step1.complete')
|
|
221
|
+
graph.addNode('Step2.complete')
|
|
222
|
+
graph.addNode('Step3.complete')
|
|
223
|
+
|
|
224
|
+
const groups = graph.getParallelGroups()
|
|
225
|
+
expect(groups).toHaveLength(1)
|
|
226
|
+
expect(groups[0]?.nodes).toHaveLength(3)
|
|
227
|
+
expect(groups[0]?.level).toBe(0)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('should separate dependent nodes into different levels', () => {
|
|
231
|
+
graph.addNode('Step1.complete')
|
|
232
|
+
graph.addNode('Step2.complete', { dependsOn: 'Step1.complete' })
|
|
233
|
+
|
|
234
|
+
const groups = graph.getParallelGroups()
|
|
235
|
+
expect(groups).toHaveLength(2)
|
|
236
|
+
|
|
237
|
+
const level0 = groups.find((g) => g.level === 0)
|
|
238
|
+
const level1 = groups.find((g) => g.level === 1)
|
|
239
|
+
|
|
240
|
+
expect(level0?.nodes).toContain('Step1.complete')
|
|
241
|
+
expect(level1?.nodes).toContain('Step2.complete')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('should handle complex dependency graphs', () => {
|
|
245
|
+
// Level 0: A, B (no dependencies)
|
|
246
|
+
// Level 1: C (depends on A), D (depends on B)
|
|
247
|
+
// Level 2: E (depends on C and D)
|
|
248
|
+
graph.addNode('StepA.complete')
|
|
249
|
+
graph.addNode('StepB.complete')
|
|
250
|
+
graph.addNode('StepC.complete', { dependsOn: 'StepA.complete' })
|
|
251
|
+
graph.addNode('StepD.complete', { dependsOn: 'StepB.complete' })
|
|
252
|
+
graph.addNode('StepE.complete', {
|
|
253
|
+
dependsOn: ['StepC.complete', 'StepD.complete'],
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const groups = graph.getParallelGroups()
|
|
257
|
+
expect(groups).toHaveLength(3)
|
|
258
|
+
|
|
259
|
+
const level0 = groups.find((g) => g.level === 0)
|
|
260
|
+
const level1 = groups.find((g) => g.level === 1)
|
|
261
|
+
const level2 = groups.find((g) => g.level === 2)
|
|
262
|
+
|
|
263
|
+
expect(level0?.nodes).toHaveLength(2)
|
|
264
|
+
expect(level0?.nodes).toContain('StepA.complete')
|
|
265
|
+
expect(level0?.nodes).toContain('StepB.complete')
|
|
266
|
+
|
|
267
|
+
expect(level1?.nodes).toHaveLength(2)
|
|
268
|
+
expect(level1?.nodes).toContain('StepC.complete')
|
|
269
|
+
expect(level1?.nodes).toContain('StepD.complete')
|
|
270
|
+
|
|
271
|
+
expect(level2?.nodes).toHaveLength(1)
|
|
272
|
+
expect(level2?.nodes).toContain('StepE.complete')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('should return groups sorted by level', () => {
|
|
276
|
+
graph.addNode('Step1.complete')
|
|
277
|
+
graph.addNode('Step2.complete', { dependsOn: 'Step1.complete' })
|
|
278
|
+
graph.addNode('Step3.complete', { dependsOn: 'Step2.complete' })
|
|
279
|
+
|
|
280
|
+
const groups = graph.getParallelGroups()
|
|
281
|
+
|
|
282
|
+
for (let i = 1; i < groups.length; i++) {
|
|
283
|
+
const prevLevel = groups[i - 1]?.level ?? -1
|
|
284
|
+
const currLevel = groups[i]?.level ?? -1
|
|
285
|
+
expect(currLevel).toBeGreaterThan(prevLevel)
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe('getDependencies()', () => {
|
|
291
|
+
it('should return empty array for node without dependencies', () => {
|
|
292
|
+
graph.addNode('Step1.complete')
|
|
293
|
+
|
|
294
|
+
expect(graph.getDependencies('Step1.complete')).toEqual([])
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('should return direct dependencies only', () => {
|
|
298
|
+
graph.addNode('Step1.complete')
|
|
299
|
+
graph.addNode('Step2.complete', { dependsOn: 'Step1.complete' })
|
|
300
|
+
graph.addNode('Step3.complete', { dependsOn: 'Step2.complete' })
|
|
301
|
+
|
|
302
|
+
expect(graph.getDependencies('Step3.complete')).toEqual([
|
|
303
|
+
'Step2.complete',
|
|
304
|
+
])
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('should throw for non-existent node', () => {
|
|
308
|
+
expect(() => {
|
|
309
|
+
graph.getDependencies('NonExistent.complete')
|
|
310
|
+
}).toThrow(MissingDependencyError)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe('getAllDependencies()', () => {
|
|
315
|
+
it('should return all transitive dependencies', () => {
|
|
316
|
+
graph.addNode('Step1.complete')
|
|
317
|
+
graph.addNode('Step2.complete', { dependsOn: 'Step1.complete' })
|
|
318
|
+
graph.addNode('Step3.complete', { dependsOn: 'Step2.complete' })
|
|
319
|
+
|
|
320
|
+
const allDeps = graph.getAllDependencies('Step3.complete')
|
|
321
|
+
expect(allDeps).toContain('Step1.complete')
|
|
322
|
+
expect(allDeps).toContain('Step2.complete')
|
|
323
|
+
expect(allDeps).toHaveLength(2)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('should handle diamond dependencies without duplicates', () => {
|
|
327
|
+
graph.addNode('StepA.complete')
|
|
328
|
+
graph.addNode('StepB.complete', { dependsOn: 'StepA.complete' })
|
|
329
|
+
graph.addNode('StepC.complete', { dependsOn: 'StepA.complete' })
|
|
330
|
+
graph.addNode('StepD.complete', {
|
|
331
|
+
dependsOn: ['StepB.complete', 'StepC.complete'],
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
const allDeps = graph.getAllDependencies('StepD.complete')
|
|
335
|
+
expect(allDeps).toHaveLength(3)
|
|
336
|
+
expect(allDeps.filter((d) => d === 'StepA.complete')).toHaveLength(1)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe('getDependents()', () => {
|
|
341
|
+
it('should return nodes that depend on given node', () => {
|
|
342
|
+
graph.addNode('Step1.complete')
|
|
343
|
+
graph.addNode('Step2.complete', { dependsOn: 'Step1.complete' })
|
|
344
|
+
graph.addNode('Step3.complete', { dependsOn: 'Step1.complete' })
|
|
345
|
+
|
|
346
|
+
const dependents = graph.getDependents('Step1.complete')
|
|
347
|
+
expect(dependents).toContain('Step2.complete')
|
|
348
|
+
expect(dependents).toContain('Step3.complete')
|
|
349
|
+
expect(dependents).toHaveLength(2)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('should return empty array for leaf nodes', () => {
|
|
353
|
+
graph.addNode('Step1.complete')
|
|
354
|
+
graph.addNode('Step2.complete', { dependsOn: 'Step1.complete' })
|
|
355
|
+
|
|
356
|
+
expect(graph.getDependents('Step2.complete')).toEqual([])
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
describe('getHardDependencies()', () => {
|
|
361
|
+
it('should return only hard dependencies', () => {
|
|
362
|
+
graph.addNode('Step1.complete')
|
|
363
|
+
graph.addNode('Step2.complete')
|
|
364
|
+
graph.addNode('Step3.complete', {
|
|
365
|
+
dependsOn: ['Step1.complete', 'Step2.complete'],
|
|
366
|
+
})
|
|
367
|
+
// Make Step2 a soft dependency
|
|
368
|
+
graph.addNode('Step4.complete', {
|
|
369
|
+
dependsOn: 'Step2.complete',
|
|
370
|
+
type: 'soft',
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
const hardDeps = graph.getHardDependencies('Step3.complete')
|
|
374
|
+
expect(hardDeps).toContain('Step1.complete')
|
|
375
|
+
expect(hardDeps).toContain('Step2.complete')
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('should exclude soft dependencies', () => {
|
|
379
|
+
graph.addNode('Step1.complete')
|
|
380
|
+
graph.addNode('Step2.complete', {
|
|
381
|
+
dependsOn: 'Step1.complete',
|
|
382
|
+
type: 'soft',
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
const hardDeps = graph.getHardDependencies('Step2.complete')
|
|
386
|
+
expect(hardDeps).toEqual([])
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
describe('getSoftDependencies()', () => {
|
|
391
|
+
it('should return only soft dependencies', () => {
|
|
392
|
+
graph.addNode('Step1.complete')
|
|
393
|
+
graph.addNode('Step2.complete', {
|
|
394
|
+
dependsOn: 'Step1.complete',
|
|
395
|
+
type: 'soft',
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const softDeps = graph.getSoftDependencies('Step2.complete')
|
|
399
|
+
expect(softDeps).toContain('Step1.complete')
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
describe('graph visualization helpers', () => {
|
|
404
|
+
it('should export to DOT format', () => {
|
|
405
|
+
graph.addNode('Step1.complete')
|
|
406
|
+
graph.addNode('Step2.complete', { dependsOn: 'Step1.complete' })
|
|
407
|
+
|
|
408
|
+
const dot = graph.toDot()
|
|
409
|
+
expect(dot).toContain('digraph')
|
|
410
|
+
expect(dot).toContain('Step1.complete')
|
|
411
|
+
expect(dot).toContain('Step2.complete')
|
|
412
|
+
expect(dot).toContain('->')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('should export to JSON format', () => {
|
|
416
|
+
graph.addNode('Step1.complete')
|
|
417
|
+
graph.addNode('Step2.complete', { dependsOn: 'Step1.complete' })
|
|
418
|
+
|
|
419
|
+
const json = graph.toJSON()
|
|
420
|
+
expect(json.nodes).toHaveLength(2)
|
|
421
|
+
expect(json.edges).toHaveLength(1)
|
|
422
|
+
})
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
describe('fromEventRegistrations()', () => {
|
|
426
|
+
it('should build graph from event registrations with dependencies', () => {
|
|
427
|
+
const registrations = [
|
|
428
|
+
{
|
|
429
|
+
noun: 'Step1',
|
|
430
|
+
event: 'complete',
|
|
431
|
+
handler: () => {},
|
|
432
|
+
source: '',
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
noun: 'Step2',
|
|
436
|
+
event: 'complete',
|
|
437
|
+
handler: () => {},
|
|
438
|
+
source: '',
|
|
439
|
+
dependencies: { dependsOn: 'Step1.complete' } as DependencyConfig,
|
|
440
|
+
},
|
|
441
|
+
]
|
|
442
|
+
|
|
443
|
+
const graph = DependencyGraph.fromEventRegistrations(registrations)
|
|
444
|
+
expect(graph.getNodes()).toHaveLength(2)
|
|
445
|
+
expect(graph.getDependencies('Step2.complete')).toEqual([
|
|
446
|
+
'Step1.complete',
|
|
447
|
+
])
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('should handle registrations without dependencies', () => {
|
|
451
|
+
const registrations = [
|
|
452
|
+
{
|
|
453
|
+
noun: 'Step1',
|
|
454
|
+
event: 'complete',
|
|
455
|
+
handler: () => {},
|
|
456
|
+
source: '',
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
noun: 'Step2',
|
|
460
|
+
event: 'complete',
|
|
461
|
+
handler: () => {},
|
|
462
|
+
source: '',
|
|
463
|
+
},
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
const graph = DependencyGraph.fromEventRegistrations(registrations)
|
|
467
|
+
expect(graph.getNodes()).toHaveLength(2)
|
|
468
|
+
expect(graph.getDependencies('Step1.complete')).toEqual([])
|
|
469
|
+
expect(graph.getDependencies('Step2.complete')).toEqual([])
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
describe('CircularDependencyError', () => {
|
|
475
|
+
it('should include cycle path in error', () => {
|
|
476
|
+
const error = new CircularDependencyError([
|
|
477
|
+
'StepA.complete',
|
|
478
|
+
'StepB.complete',
|
|
479
|
+
'StepA.complete',
|
|
480
|
+
])
|
|
481
|
+
expect(error.cyclePath).toEqual([
|
|
482
|
+
'StepA.complete',
|
|
483
|
+
'StepB.complete',
|
|
484
|
+
'StepA.complete',
|
|
485
|
+
])
|
|
486
|
+
expect(error.message).toContain('Circular dependency detected')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('should format cycle path in message', () => {
|
|
490
|
+
const error = new CircularDependencyError([
|
|
491
|
+
'StepA.complete',
|
|
492
|
+
'StepB.complete',
|
|
493
|
+
'StepC.complete',
|
|
494
|
+
'StepA.complete',
|
|
495
|
+
])
|
|
496
|
+
expect(error.message).toContain(
|
|
497
|
+
'StepA.complete -> StepB.complete -> StepC.complete -> StepA.complete'
|
|
498
|
+
)
|
|
499
|
+
})
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
describe('MissingDependencyError', () => {
|
|
503
|
+
it('should include dependency name in error', () => {
|
|
504
|
+
const error = new MissingDependencyError(
|
|
505
|
+
'NonExistent.complete',
|
|
506
|
+
'Step2.complete'
|
|
507
|
+
)
|
|
508
|
+
expect(error.dependency).toBe('NonExistent.complete')
|
|
509
|
+
expect(error.node).toBe('Step2.complete')
|
|
510
|
+
expect(error.message).toContain('NonExistent.complete')
|
|
511
|
+
})
|
|
512
|
+
})
|