From e11e9f4c249b55eb701b81e869ec91f4e9979ff9 Mon Sep 17 00:00:00 2001 From: David Novakovic Date: Tue, 25 Nov 2025 12:49:19 +1000 Subject: [PATCH 1/3] feat: first cut at a little visualisation util for the dag. --- index.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++ test/print.spec.ts | 54 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 test/print.spec.ts diff --git a/index.ts b/index.ts index 886a0b4..1d94d92 100644 --- a/index.ts +++ b/index.ts @@ -112,6 +112,66 @@ class DAGraph { return reverseGraph; } + /** + * Returns a string representation of the graph structure in a tree-like format. + * + * @param labelFn optional function to generate a label for each node. Defaults to node.id. + * @returns a string representing the graph. + */ + print(labelFn: (n: T) => string = n => n.id): string { + const lines: string[] = []; + const outgoing = new Map(); + + for (const node of this.nodesById.values()) { + for (const depId of node.dependencies) { + let children = outgoing.get(depId); + if (!children) { + children = []; + outgoing.set(depId, children); + } + children.push(node.id); + } + } + + const sortNodes = (aId: string, bId: string) => { + const aNode = this.nodesById.get(aId); + const bNode = this.nodesById.get(bId); + if (!aNode || !bNode) { + throw new Error('Node not found in graph'); + } + return labelFn(aNode.data).localeCompare(labelFn(bNode.data)); + }; + + const visit = (nodeId: string, prefix: string) => { + const children = outgoing.get(nodeId) || []; + children.sort(sortNodes); + + children.forEach((childId, index) => { + const isLast = index === children.length - 1; + const childNode = this.nodesById.get(childId); + if (!childNode) { + throw new Error('Node not found in graph'); + } + const label = labelFn(childNode.data); + const connector = isLast ? '└── ' : '├── '; + + lines.push(`${prefix}${connector}${label}`); + + const childPrefix = prefix + (isLast ? ' ' : '│ '); + visit(childId, childPrefix); + }); + }; + + const roots = [...this.roots()].sort((a, b) => labelFn(a).localeCompare(labelFn(b))); + + roots.forEach(root => { + lines.push(labelFn(root)); + visit(root.id, ''); + }); + + return lines.join('\n'); + } + private ensureNode(data: T): Node { let node = this.nodesById.get(data.id); if (node) { diff --git a/test/print.spec.ts b/test/print.spec.ts new file mode 100644 index 0000000..76d5b89 --- /dev/null +++ b/test/print.spec.ts @@ -0,0 +1,54 @@ +import 'jest-extended'; +import createDAG from '../index'; + +describe('DAGraph.print', () => { + test('should print a simple tree structure', () => { + const dag = createDAG<{ id: string }>(); + dag.addEdge({ id: 'A' }, { id: 'B' }); + dag.addEdge({ id: 'B' }, { id: 'C' }); + dag.addEdge({ id: 'A' }, { id: 'D' }); + + // A -> B -> C + // -> D + + const output = dag.print(); + const expected = ['A', '├── B', '│ └── C', '└── D'].join('\n'); + + expect(output).toBe(expected); + }); + + test('should handle multiple roots', () => { + const dag = createDAG<{ id: string }>(); + dag.addEdge({ id: 'A' }, { id: 'C' }); + dag.addEdge({ id: 'B' }, { id: 'C' }); + + // A -> C + // B -> C + + const output = dag.print(); + const expected = ['A', '└── C', 'B', '└── C'].join('\n'); + + expect(output).toBe(expected); + }); + + test('should use custom label function', () => { + const dag = createDAG<{ id: string; name: string }>(); + dag.addEdge({ id: '1', name: 'First' }, { id: '2', name: 'Second' }); + + const output = dag.print(n => n.name); + const expected = ['First', '└── Second'].join('\n'); + + expect(output).toBe(expected); + }); + + test('should handle disconnected nodes', () => { + const dag = createDAG<{ id: string }>(); + dag.addNode({ id: 'A' }); + dag.addNode({ id: 'B' }); + + const output = dag.print(); + const expected = ['A', 'B'].join('\n'); + + expect(output).toBe(expected); + }); +}); From 2fb38b9bad3c32724de39dca7053f82d3b1b2274 Mon Sep 17 00:00:00 2001 From: David Novakovic Date: Wed, 26 Nov 2025 10:57:50 +1000 Subject: [PATCH 2/3] feat: use a generic traversal function instead of a single use print --- index.ts | 59 +++++++++--------------- test/DAGraph.spec.ts | 104 ++++++++++++++++++++++++++++++++----------- test/print.spec.ts | 54 ---------------------- 3 files changed, 99 insertions(+), 118 deletions(-) delete mode 100644 test/print.spec.ts diff --git a/index.ts b/index.ts index 1d94d92..6d7af81 100644 --- a/index.ts +++ b/index.ts @@ -2,6 +2,8 @@ interface Identifiable { readonly id: string; } +type DAGVisitor = (node: T, parent: T | null, depth: number, context: C) => void; + class Node { constructor(readonly data: T, readonly dependencies = new Set()) {} @@ -113,15 +115,13 @@ class DAGraph { } /** - * Returns a string representation of the graph structure in a tree-like format. + * Traverses the graph in depth-first order and calls the visitor function for each node. * - * @param labelFn optional function to generate a label for each node. Defaults to node.id. - * @returns a string representing the graph. + * @param visitor the visitor function to call for each node. + * @param context the context object to pass to the visitor. */ - print(labelFn: (n: T) => string = n => n.id): string { - const lines: string[] = []; + traverse(visitor: DAGVisitor, context: C): void { const outgoing = new Map(); - for (const node of this.nodesById.values()) { for (const depId of node.dependencies) { let children = outgoing.get(depId); @@ -133,43 +133,24 @@ class DAGraph { } } - const sortNodes = (aId: string, bId: string) => { - const aNode = this.nodesById.get(aId); - const bNode = this.nodesById.get(bId); - if (!aNode || !bNode) { - throw new Error('Node not found in graph'); + const visitNode = (nodeId: string, parent: T | null, depth: number) => { + const node = this.nodesById.get(nodeId); + if (!node) { + return; } - return labelFn(aNode.data).localeCompare(labelFn(bNode.data)); - }; - - const visit = (nodeId: string, prefix: string) => { - const children = outgoing.get(nodeId) || []; - children.sort(sortNodes); - - children.forEach((childId, index) => { - const isLast = index === children.length - 1; - const childNode = this.nodesById.get(childId); - if (!childNode) { - throw new Error('Node not found in graph'); - } - const label = labelFn(childNode.data); - const connector = isLast ? '└── ' : '├── '; - lines.push(`${prefix}${connector}${label}`); + visitor(node.data, parent, depth, context); - const childPrefix = prefix + (isLast ? ' ' : '│ '); - visit(childId, childPrefix); - }); + const children = outgoing.get(nodeId) || []; + for (const childId of children) { + visitNode(childId, node.data, depth + 1); + } }; - const roots = [...this.roots()].sort((a, b) => labelFn(a).localeCompare(labelFn(b))); - - roots.forEach(root => { - lines.push(labelFn(root)); - visit(root.id, ''); - }); - - return lines.join('\n'); + const roots = [...this.roots()]; + for (const root of roots) { + visitNode(root.id, null, 0); + } } private ensureNode(data: T): Node { @@ -221,6 +202,6 @@ function createDAG(): DAGraph { return new DAGraph(); } -export type { DAGraph, Identifiable }; +export type { DAGraph, Identifiable, DAGVisitor }; export default createDAG; export { createDAG }; diff --git a/test/DAGraph.spec.ts b/test/DAGraph.spec.ts index 8ee64e6..852eb64 100644 --- a/test/DAGraph.spec.ts +++ b/test/DAGraph.spec.ts @@ -157,33 +157,87 @@ describe('DAGraph', () => { expect([...dag.reverse().topologicalSort()]).toEqual(expected); - const myDAG = createDAG(); - myDAG.addEdge( - { - id: 'a', - name: 'my thing', - doSomething: () => { - console.log('A'); - } - }, - { - id: 'b', - name: 'my other thing', - doSomething: () => { - console.log('B'); - } - } - ); + // Additional verification for simple case + const smallDag = createDAG(); + const a = { id: 'A' }; + const b = { id: 'B' }; + smallDag.addEdge(a, b); + expect([...smallDag.reverse().topologicalSort()]).toEqual([b, a]); }); }); -}); -type MyThing = { - id: string; - name: string; - doSomething(): void; -}; + describe('traverse', () => { + test('should visit nodes in depth-first order', () => { + const dag = createDAG(); + const nodeA = aNode('A'); + const nodeB = aNode('B'); + const nodeC = aNode('C'); + const nodeD = aNode('D'); + + dag.addEdge(nodeA, nodeB); + dag.addEdge(nodeB, nodeC); + dag.addEdge(nodeA, nodeD); + + // Structure: + // A -> B -> C + // A -> D + + const visited: string[] = []; + dag.traverse((node, parent, depth) => { + visited.push(`${node.id}:${depth}`); + }, {}); + + // Since traverse uses roots() which iterates over Map.values(), the order of branches is insertion order related but implementation specific for Map. + // We just check that we visited everyone and depths are correct. + expect(visited).toIncludeAllMembers(['A:0', 'B:1', 'C:2', 'D:1']); + }); + + test('should pass context to visitor', () => { + const dag = createDAG(); + const nodeA = aNode('A'); + dag.addNode(nodeA); + + const context = { count: 0 }; + dag.traverse((_node, _parent, _depth, ctx) => { + ctx.count++; + }, context); + + expect(context.count).toBe(1); + }); + + test('should provide parent node', () => { + const dag = createDAG(); + const nodeA = aNode('A'); + const nodeB = aNode('B'); + dag.addEdge(nodeA, nodeB); + + const parents: Record = {}; + dag.traverse((node, parent) => { + parents[node.id] = parent ? parent.id : null; + }, {}); + + expect(parents['A']).toBeNull(); + expect(parents['B']).toBe('A'); + }); + + test('should handle multiple roots', () => { + const dag = createDAG(); + const nodeA = aNode('A'); + const nodeB = aNode('B'); + const nodeC = aNode('C'); + + dag.addNode(nodeA); + dag.addNode(nodeB); + dag.addEdge(nodeB, nodeC); + + const visited: string[] = []; + dag.traverse(node => visited.push(node.id), {}); + + expect(visited).toIncludeSameMembers(['A', 'B', 'C']); + }); + }); +}); -function aNode(): Identifiable { - return { id: uuid() }; +function aNode(id?: string): Identifiable { + return { id: id || uuid() }; } diff --git a/test/print.spec.ts b/test/print.spec.ts deleted file mode 100644 index 76d5b89..0000000 --- a/test/print.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import 'jest-extended'; -import createDAG from '../index'; - -describe('DAGraph.print', () => { - test('should print a simple tree structure', () => { - const dag = createDAG<{ id: string }>(); - dag.addEdge({ id: 'A' }, { id: 'B' }); - dag.addEdge({ id: 'B' }, { id: 'C' }); - dag.addEdge({ id: 'A' }, { id: 'D' }); - - // A -> B -> C - // -> D - - const output = dag.print(); - const expected = ['A', '├── B', '│ └── C', '└── D'].join('\n'); - - expect(output).toBe(expected); - }); - - test('should handle multiple roots', () => { - const dag = createDAG<{ id: string }>(); - dag.addEdge({ id: 'A' }, { id: 'C' }); - dag.addEdge({ id: 'B' }, { id: 'C' }); - - // A -> C - // B -> C - - const output = dag.print(); - const expected = ['A', '└── C', 'B', '└── C'].join('\n'); - - expect(output).toBe(expected); - }); - - test('should use custom label function', () => { - const dag = createDAG<{ id: string; name: string }>(); - dag.addEdge({ id: '1', name: 'First' }, { id: '2', name: 'Second' }); - - const output = dag.print(n => n.name); - const expected = ['First', '└── Second'].join('\n'); - - expect(output).toBe(expected); - }); - - test('should handle disconnected nodes', () => { - const dag = createDAG<{ id: string }>(); - dag.addNode({ id: 'A' }); - dag.addNode({ id: 'B' }); - - const output = dag.print(); - const expected = ['A', 'B'].join('\n'); - - expect(output).toBe(expected); - }); -}); From aec9b665fe0646fd8793d53d374ed88131a85772 Mon Sep 17 00:00:00 2001 From: David Novakovic Date: Wed, 26 Nov 2025 11:15:15 +1000 Subject: [PATCH 3/3] feat: include extra visitor params to help with dag context while traversing. --- index.ts | 27 +++++++++++++++++---------- test/DAGraph.spec.ts | 23 ++++++++++++++++++++++- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/index.ts b/index.ts index 6d7af81..27f50ac 100644 --- a/index.ts +++ b/index.ts @@ -2,7 +2,7 @@ interface Identifiable { readonly id: string; } -type DAGVisitor = (node: T, parent: T | null, depth: number, context: C) => void; +type DAGVisitor = (node: T, parent: T | null, depth: number, index: number, total: number, context: C) => void; class Node { constructor(readonly data: T, readonly dependencies = new Set()) {} @@ -117,7 +117,14 @@ class DAGraph { /** * Traverses the graph in depth-first order and calls the visitor function for each node. * - * @param visitor the visitor function to call for each node. + * @param visitor the visitor function to call for each node. The visitor signature is: + * (node: T, parent: T | null, depth: number, index: number, total: number, context: C) => void + * - node: the current node data + * - parent: the parent node data (null for roots) + * - depth: the depth of the current node (0 for roots) + * - index: the index of the current node among its siblings + * - total: the total number of siblings + * - context: the context object passed to traverse * @param context the context object to pass to the visitor. */ traverse(visitor: DAGVisitor, context: C): void { @@ -133,24 +140,24 @@ class DAGraph { } } - const visitNode = (nodeId: string, parent: T | null, depth: number) => { + const visitNode = (nodeId: string, parent: T | null, depth: number, index: number, total: number) => { const node = this.nodesById.get(nodeId); if (!node) { return; } - visitor(node.data, parent, depth, context); + visitor(node.data, parent, depth, index, total, context); const children = outgoing.get(nodeId) || []; - for (const childId of children) { - visitNode(childId, node.data, depth + 1); - } + children.forEach((childId, i) => { + visitNode(childId, node.data, depth + 1, i, children.length); + }); }; const roots = [...this.roots()]; - for (const root of roots) { - visitNode(root.id, null, 0); - } + roots.forEach((root, i) => { + visitNode(root.id, null, 0, i, roots.length); + }); } private ensureNode(data: T): Node { diff --git a/test/DAGraph.spec.ts b/test/DAGraph.spec.ts index 852eb64..3d4d024 100644 --- a/test/DAGraph.spec.ts +++ b/test/DAGraph.spec.ts @@ -198,7 +198,7 @@ describe('DAGraph', () => { dag.addNode(nodeA); const context = { count: 0 }; - dag.traverse((_node, _parent, _depth, ctx) => { + dag.traverse((_node, _parent, _depth, _index, _total, ctx) => { ctx.count++; }, context); @@ -235,6 +235,27 @@ describe('DAGraph', () => { expect(visited).toIncludeSameMembers(['A', 'B', 'C']); }); + + test('should provide index and total siblings count', () => { + const dag = createDAG(); + const a = { id: 'A' }; + const b = { id: 'B' }; + const c = { id: 'C' }; + + // A -> B + // A -> C + dag.addEdge(a, b); + dag.addEdge(a, c); + + const visits: string[] = []; + dag.traverse((node, _parent, _depth, index, total) => { + visits.push(`${node.id}(${index}/${total})`); + }, {}); + + // Roots: A (0/1) + // Children of A: B (0/2), C (1/2) - deterministic order due to insertion + expect(visits).toIncludeSameMembers(['A(0/1)', 'B(0/2)', 'C(1/2)']); + }); }); });