diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7e80190 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,23 @@ +Original dagre-d3 copyright: Copyright (c) 2013 Chris Pettitt +Original dagre copyright: Copyright (c) 2012-2014 Chris Pettitt +Original graphlib copyright: Copyright (c) 2012-2014 Chris Pettitt + +Copyright (c) 2022-2024 Thibaut Lassalle, David Newell, Alois Klink, Sidharth Vinod and dagre-es contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/dagre/acyclic.test.js b/src/dagre/acyclic.test.js new file mode 100644 index 0000000..2d82b36 --- /dev/null +++ b/src/dagre/acyclic.test.js @@ -0,0 +1,98 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import * as acyclic from './acyclic.js'; +import { Graph } from '../graphlib/index.js'; +import { findCycles } from '../graphlib/alg/find-cycles.js'; + +describe('acyclic', function () { + var ACYCLICERS = ['greedy', 'dfs', 'unknown-should-still-work']; + var g; + + beforeEach(function () { + g = new Graph({ multigraph: true }).setDefaultEdgeLabel(function () { + return { minlen: 1, weight: 1 }; + }); + }); + + _.forEach(ACYCLICERS, function (acyclicer) { + describe(acyclicer, function () { + beforeEach(function () { + g.setGraph({ acyclicer: acyclicer }); + }); + + describe('run', function () { + it('does not change an already acyclic graph', function () { + g.setPath(['a', 'b', 'd']); + g.setPath(['a', 'c', 'd']); + acyclic.run(g); + var results = _.map(g.edges(), stripLabel); + expect(_.sortBy(results, ['v', 'w'])).to.eql([ + { v: 'a', w: 'b' }, + { v: 'a', w: 'c' }, + { v: 'b', w: 'd' }, + { v: 'c', w: 'd' }, + ]); + }); + + it('breaks cycles in the input graph', function () { + g.setPath(['a', 'b', 'c', 'd', 'a']); + acyclic.run(g); + expect(findCycles(g)).to.eql([]); + }); + + it('creates a multi-edge where necessary', function () { + g.setPath(['a', 'b', 'a']); + acyclic.run(g); + expect(findCycles(g)).to.eql([]); + if (g.hasEdge('a', 'b')) { + expect(g.outEdges('a', 'b')).to.have.length(2); + } else { + expect(g.outEdges('b', 'a')).to.have.length(2); + } + expect(g.edgeCount()).to.equal(2); + }); + }); + + describe('undo', function () { + it('does not change edges where the original graph was acyclic', function () { + g.setEdge('a', 'b', { minlen: 2, weight: 3 }); + acyclic.run(g); + acyclic.undo(g); + expect(g.edge('a', 'b')).to.eql({ minlen: 2, weight: 3 }); + expect(g.edges()).to.have.length(1); + }); + + it('can restore previosuly reversed edges', function () { + g.setEdge('a', 'b', { minlen: 2, weight: 3 }); + g.setEdge('b', 'a', { minlen: 3, weight: 4 }); + acyclic.run(g); + acyclic.undo(g); + expect(g.edge('a', 'b')).to.eql({ minlen: 2, weight: 3 }); + expect(g.edge('b', 'a')).to.eql({ minlen: 3, weight: 4 }); + expect(g.edges()).to.have.length(2); + }); + }); + }); + }); + + describe('greedy-specific functionality', function () { + it('prefers to break cycles at low-weight edges', function () { + g.setGraph({ acyclicer: 'greedy' }); + g.setDefaultEdgeLabel(function () { + return { minlen: 1, weight: 2 }; + }); + g.setPath(['a', 'b', 'c', 'd', 'a']); + g.setEdge('c', 'd', { weight: 1 }); + acyclic.run(g); + expect(findCycles(g)).to.eql([]); + expect(g.hasEdge('c', 'd')).to.be.false; + }); + }); +}); + +function stripLabel(edge) { + var c = _.clone(edge); + delete c.label; + return c; +} diff --git a/src/dagre/add-border-segments.test.js b/src/dagre/add-border-segments.test.js new file mode 100644 index 0000000..595dd14 --- /dev/null +++ b/src/dagre/add-border-segments.test.js @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { addBorderSegments } from './add-border-segments.js'; +import { Graph } from '../graphlib/index.js'; + +describe('addBorderSegments', function () { + var g; + + beforeEach(function () { + g = new Graph({ compound: true }); + }); + + it('does not add border nodes for a non-compound graph', function () { + var g = new Graph(); + g.setNode('a', { rank: 0 }); + addBorderSegments(g); + expect(g.nodeCount()).to.equal(1); + expect(g.node('a')).to.eql({ rank: 0 }); + }); + + it('does not add border nodes for a graph with no clusters', function () { + g.setNode('a', { rank: 0 }); + addBorderSegments(g); + expect(g.nodeCount()).to.equal(1); + expect(g.node('a')).to.eql({ rank: 0 }); + }); + + it('adds a border for a single-rank subgraph', function () { + g.setNode('sg', { minRank: 1, maxRank: 1 }); + addBorderSegments(g); + + var bl = g.node('sg').borderLeft[1]; + var br = g.node('sg').borderRight[1]; + expect(g.node(bl)).eqls({ + dummy: 'border', + borderType: 'borderLeft', + rank: 1, + width: 0, + height: 0, + }); + expect(g.parent(bl)).equals('sg'); + expect(g.node(br)).eqls({ + dummy: 'border', + borderType: 'borderRight', + rank: 1, + width: 0, + height: 0, + }); + expect(g.parent(br)).equals('sg'); + }); + + it('adds a border for a multi-rank subgraph', function () { + g.setNode('sg', { minRank: 1, maxRank: 2 }); + addBorderSegments(g); + + var sgNode = g.node('sg'); + var bl2 = sgNode.borderLeft[1]; + var br2 = sgNode.borderRight[1]; + expect(g.node(bl2)).eqls({ + dummy: 'border', + borderType: 'borderLeft', + rank: 1, + width: 0, + height: 0, + }); + expect(g.parent(bl2)).equals('sg'); + expect(g.node(br2)).eqls({ + dummy: 'border', + borderType: 'borderRight', + rank: 1, + width: 0, + height: 0, + }); + expect(g.parent(br2)).equals('sg'); + + var bl1 = sgNode.borderLeft[2]; + var br1 = sgNode.borderRight[2]; + expect(g.node(bl1)).eqls({ + dummy: 'border', + borderType: 'borderLeft', + rank: 2, + width: 0, + height: 0, + }); + expect(g.parent(bl1)).equals('sg'); + expect(g.node(br1)).eqls({ + dummy: 'border', + borderType: 'borderRight', + rank: 2, + width: 0, + height: 0, + }); + expect(g.parent(br1)).equals('sg'); + + expect(g.hasEdge(sgNode.borderLeft[1], sgNode.borderLeft[2])).to.be.true; + expect(g.hasEdge(sgNode.borderRight[1], sgNode.borderRight[2])).to.be.true; + }); + + it('adds borders for nested subgraphs', function () { + g.setNode('sg1', { minRank: 1, maxRank: 1 }); + g.setNode('sg2', { minRank: 1, maxRank: 1 }); + g.setParent('sg2', 'sg1'); + addBorderSegments(g); + + var bl1 = g.node('sg1').borderLeft[1]; + var br1 = g.node('sg1').borderRight[1]; + expect(g.node(bl1)).eqls({ + dummy: 'border', + borderType: 'borderLeft', + rank: 1, + width: 0, + height: 0, + }); + expect(g.parent(bl1)).equals('sg1'); + expect(g.node(br1)).eqls({ + dummy: 'border', + borderType: 'borderRight', + rank: 1, + width: 0, + height: 0, + }); + expect(g.parent(br1)).equals('sg1'); + + var bl2 = g.node('sg2').borderLeft[1]; + var br2 = g.node('sg2').borderRight[1]; + expect(g.node(bl2)).eqls({ + dummy: 'border', + borderType: 'borderLeft', + rank: 1, + width: 0, + height: 0, + }); + expect(g.parent(bl2)).equals('sg2'); + expect(g.node(br2)).eqls({ + dummy: 'border', + borderType: 'borderRight', + rank: 1, + width: 0, + height: 0, + }); + expect(g.parent(br2)).equals('sg2'); + }); +}); diff --git a/src/dagre/coordinate-system.test.js b/src/dagre/coordinate-system.test.js new file mode 100644 index 0000000..d9a1b1e --- /dev/null +++ b/src/dagre/coordinate-system.test.js @@ -0,0 +1,71 @@ +import { Graph } from '../graphlib/index.js'; +import * as coordinateSystem from './coordinate-system.js'; +import { describe, expect, it } from 'vitest'; + +describe('coordinateSystem', function () { + var g; + + beforeEach(function () { + g = new Graph(); + }); + + describe('coordinateSystem.adjust', function () { + beforeEach(function () { + g.setNode('a', { width: 100, height: 200 }); + }); + + it('does nothing to node dimensions with rankdir = TB', function () { + g.setGraph({ rankdir: 'TB' }); + coordinateSystem.adjust(g); + expect(g.node('a')).eqls({ width: 100, height: 200 }); + }); + + it('does nothing to node dimensions with rankdir = BT', function () { + g.setGraph({ rankdir: 'BT' }); + coordinateSystem.adjust(g); + expect(g.node('a')).eqls({ width: 100, height: 200 }); + }); + + it('swaps width and height for nodes with rankdir = LR', function () { + g.setGraph({ rankdir: 'LR' }); + coordinateSystem.adjust(g); + expect(g.node('a')).eqls({ width: 200, height: 100 }); + }); + + it('swaps width and height for nodes with rankdir = RL', function () { + g.setGraph({ rankdir: 'RL' }); + coordinateSystem.adjust(g); + expect(g.node('a')).eqls({ width: 200, height: 100 }); + }); + }); + + describe('coordinateSystem.undo', function () { + beforeEach(function () { + g.setNode('a', { width: 100, height: 200, x: 20, y: 40 }); + }); + + it('does nothing to points with rankdir = TB', function () { + g.setGraph({ rankdir: 'TB' }); + coordinateSystem.undo(g); + expect(g.node('a')).eqls({ x: 20, y: 40, width: 100, height: 200 }); + }); + + it('flips the y coordinate for points with rankdir = BT', function () { + g.setGraph({ rankdir: 'BT' }); + coordinateSystem.undo(g); + expect(g.node('a')).eqls({ x: 20, y: -40, width: 100, height: 200 }); + }); + + it('swaps dimensions and coordinates for points with rankdir = LR', function () { + g.setGraph({ rankdir: 'LR' }); + coordinateSystem.undo(g); + expect(g.node('a')).eqls({ x: 40, y: 20, width: 200, height: 100 }); + }); + + it('swaps dims and coords and flips x for points with rankdir = RL', function () { + g.setGraph({ rankdir: 'RL' }); + coordinateSystem.undo(g); + expect(g.node('a')).eqls({ x: -40, y: 20, width: 200, height: 100 }); + }); + }); +}); diff --git a/src/dagre/data/list.test.js b/src/dagre/data/list.test.js new file mode 100644 index 0000000..c031162 --- /dev/null +++ b/src/dagre/data/list.test.js @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { List } from './list.js'; + +describe('data.List', function () { + var list; + + beforeEach(function () { + list = new List(); + }); + + describe('dequeue', function () { + it('returns undefined with an empty list', function () { + expect(list.dequeue()).to.be.undefined; + }); + + it('unlinks and returns the first entry', function () { + var obj = {}; + list.enqueue(obj); + expect(list.dequeue()).to.equal(obj); + }); + + it('unlinks and returns multiple entries in FIFO order', function () { + var obj1 = {}; + var obj2 = {}; + list.enqueue(obj1); + list.enqueue(obj2); + + expect(list.dequeue()).to.equal(obj1); + expect(list.dequeue()).to.equal(obj2); + }); + + it('unlinks and relinks an entry if it is re-enqueued', function () { + var obj1 = {}; + var obj2 = {}; + list.enqueue(obj1); + list.enqueue(obj2); + list.enqueue(obj1); + + expect(list.dequeue()).to.equal(obj2); + expect(list.dequeue()).to.equal(obj1); + }); + + it('unlinks and relinks an entry if it is enqueued on another list', function () { + var obj = {}; + var list2 = new List(); + list.enqueue(obj); + list2.enqueue(obj); + + expect(list.dequeue()).to.be.undefined; + expect(list2.dequeue()).to.equal(obj); + }); + + it('can return a string representation', function () { + list.enqueue({ entry: 1 }); + list.enqueue({ entry: 2 }); + + expect(list.toString()).to.equal('[{"entry":1}, {"entry":2}]'); + }); + }); +}); diff --git a/src/dagre/greedy-fas.test.js b/src/dagre/greedy-fas.test.js new file mode 100644 index 0000000..1c97d8e --- /dev/null +++ b/src/dagre/greedy-fas.test.js @@ -0,0 +1,110 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graphlib/index.js'; +import { findCycles } from '../graphlib/alg/find-cycles.js'; +import { greedyFAS } from './greedy-fas.js'; + +describe('greedyFAS', function () { + var g; + + beforeEach(function () { + g = new Graph(); + }); + + it('returns the empty set for empty graphs', function () { + expect(greedyFAS(g)).to.eql([]); + }); + + it('returns the empty set for single-node graphs', function () { + g.setNode('a'); + expect(greedyFAS(g)).to.eql([]); + }); + + it('returns an empty set if the input graph is acyclic', function () { + var g = new Graph(); + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + g.setEdge('b', 'd'); + g.setEdge('a', 'e'); + expect(greedyFAS(g)).to.eql([]); + }); + + it('returns a single edge with a simple cycle', function () { + var g = new Graph(); + g.setEdge('a', 'b'); + g.setEdge('b', 'a'); + checkFAS(g, greedyFAS(g)); + }); + + it('returns a single edge in a 4-node cycle', function () { + var g = new Graph(); + g.setEdge('n1', 'n2'); + g.setPath(['n2', 'n3', 'n4', 'n5', 'n2']); + g.setEdge('n3', 'n5'); + g.setEdge('n4', 'n2'); + g.setEdge('n4', 'n6'); + checkFAS(g, greedyFAS(g)); + }); + + it('returns two edges for two 4-node cycles', function () { + var g = new Graph(); + g.setEdge('n1', 'n2'); + g.setPath(['n2', 'n3', 'n4', 'n5', 'n2']); + g.setEdge('n3', 'n5'); + g.setEdge('n4', 'n2'); + g.setEdge('n4', 'n6'); + g.setPath(['n6', 'n7', 'n8', 'n9', 'n6']); + g.setEdge('n7', 'n9'); + g.setEdge('n8', 'n6'); + g.setEdge('n8', 'n10'); + checkFAS(g, greedyFAS(g)); + }); + + it('works with arbitrarily weighted edges', function () { + // Our algorithm should also work for graphs with multi-edges, a graph + // where more than one edge can be pointing in the same direction between + // the same pair of incident nodes. We try this by assigning weights to + // our edges representing the number of edges from one node to the other. + + var g1 = new Graph(); + g1.setEdge('n1', 'n2', 2); + g1.setEdge('n2', 'n1', 1); + expect(greedyFAS(g1, weightFn(g1))).to.eql([{ v: 'n2', w: 'n1' }]); + + var g2 = new Graph(); + g2.setEdge('n1', 'n2', 1); + g2.setEdge('n2', 'n1', 2); + expect(greedyFAS(g2, weightFn(g2))).to.eql([{ v: 'n1', w: 'n2' }]); + }); + + it('works for multigraphs', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b', 5, 'foo'); + g.setEdge('b', 'a', 2, 'bar'); + g.setEdge('b', 'a', 2, 'baz'); + expect(_.sortBy(greedyFAS(g, weightFn(g)), 'name')).to.eql([ + { v: 'b', w: 'a', name: 'bar' }, + { v: 'b', w: 'a', name: 'baz' }, + ]); + }); +}); + +function checkFAS(g, fas) { + var n = g.nodeCount(); + var m = g.edgeCount(); + _.forEach(fas, function (edge) { + g.removeEdge(edge.v, edge.w); + }); + expect(findCycles(g)).to.eql([]); + // The more direct m/2 - n/6 fails for the simple cycle A <-> B, where one + // edge must be reversed, but the performance bound implies that only 2/3rds + // of an edge can be reversed. I'm using floors to acount for this. + expect(fas.length).to.be.lte(Math.floor(m / 2) - Math.floor(n / 6)); +} + +function weightFn(g) { + return function (e) { + return g.edge(e); + }; +} diff --git a/src/dagre/layout.test.js b/src/dagre/layout.test.js new file mode 100644 index 0000000..d5b526f --- /dev/null +++ b/src/dagre/layout.test.js @@ -0,0 +1,304 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { layout } from './layout.js'; +import { Graph } from '../graphlib/index.js'; + +describe('layout', function () { + var g; + + beforeEach(function () { + g = new Graph({ multigraph: true, compound: true }) + .setGraph({}) + .setDefaultEdgeLabel(function () { + return {}; + }); + }); + + it('can layout a single node', function () { + g.setNode('a', { width: 50, height: 100 }); + layout(g); + expect(extractCoordinates(g)).to.eql({ + a: { x: 50 / 2, y: 100 / 2 }, + }); + expect(g.node('a').x).to.equal(50 / 2); + expect(g.node('a').y).to.equal(100 / 2); + }); + + it('can layout two nodes on the same rank', function () { + g.graph().nodesep = 200; + g.setNode('a', { width: 50, height: 100 }); + g.setNode('b', { width: 75, height: 200 }); + layout(g); + expect(extractCoordinates(g)).to.eql({ + a: { x: 50 / 2, y: 200 / 2 }, + b: { x: 50 + 200 + 75 / 2, y: 200 / 2 }, + }); + }); + + it('can layout two nodes connected by an edge', function () { + g.graph().ranksep = 300; + g.setNode('a', { width: 50, height: 100 }); + g.setNode('b', { width: 75, height: 200 }); + g.setEdge('a', 'b'); + layout(g); + expect(extractCoordinates(g)).to.eql({ + a: { x: 75 / 2, y: 100 / 2 }, + b: { x: 75 / 2, y: 100 + 300 + 200 / 2 }, + }); + + // We should not get x, y coordinates if the edge has no label + expect(g.edge('a', 'b')).to.not.have.property('x'); + expect(g.edge('a', 'b')).to.not.have.property('y'); + }); + + it('can layout an edge with a label', function () { + g.graph().ranksep = 300; + g.setNode('a', { width: 50, height: 100 }); + g.setNode('b', { width: 75, height: 200 }); + g.setEdge('a', 'b', { width: 60, height: 70, labelpos: 'c' }); + layout(g); + expect(extractCoordinates(g)).to.eql({ + a: { x: 75 / 2, y: 100 / 2 }, + b: { x: 75 / 2, y: 100 + 150 + 70 + 150 + 200 / 2 }, + }); + expect(_.pick(g.edge('a', 'b'), ['x', 'y'])).eqls({ x: 75 / 2, y: 100 + 150 + 70 / 2 }); + }); + + describe('can layout an edge with a long label, with rankdir =', function () { + _.forEach(['TB', 'BT', 'LR', 'RL'], function (rankdir) { + it(rankdir, function () { + g.graph().nodesep = g.graph().edgesep = 10; + g.graph().rankdir = rankdir; + _.forEach(['a', 'b', 'c', 'd'], function (v) { + g.setNode(v, { width: 10, height: 10 }); + }); + g.setEdge('a', 'c', { width: 2000, height: 10, labelpos: 'c' }); + g.setEdge('b', 'd', { width: 1, height: 1 }); + layout(g); + + var p1, p2; + if (rankdir === 'TB' || rankdir === 'BT') { + p1 = g.edge('a', 'c'); + p2 = g.edge('b', 'd'); + } else { + p1 = g.node('a'); + p2 = g.node('c'); + } + + expect(Math.abs(p1.x - p2.x)).gt(1000); + }); + }); + }); + + describe('can apply an offset, with rankdir =', function () { + _.forEach(['TB', 'BT', 'LR', 'RL'], function (rankdir) { + it(rankdir, function () { + g.graph().nodesep = g.graph().edgesep = 10; + g.graph().rankdir = rankdir; + _.forEach(['a', 'b', 'c', 'd'], function (v) { + g.setNode(v, { width: 10, height: 10 }); + }); + g.setEdge('a', 'b', { width: 10, height: 10, labelpos: 'l', labeloffset: 1000 }); + g.setEdge('c', 'd', { width: 10, height: 10, labelpos: 'r', labeloffset: 1000 }); + layout(g); + + if (rankdir === 'TB' || rankdir === 'BT') { + expect(g.edge('a', 'b').x - g.edge('a', 'b').points[0].x).equals(-1000 - 10 / 2); + expect(g.edge('c', 'd').x - g.edge('c', 'd').points[0].x).equals(1000 + 10 / 2); + } else { + expect(g.edge('a', 'b').y - g.edge('a', 'b').points[0].y).equals(-1000 - 10 / 2); + expect(g.edge('c', 'd').y - g.edge('c', 'd').points[0].y).equals(1000 + 10 / 2); + } + }); + }); + }); + + it('can layout a long edge with a label', function () { + g.graph().ranksep = 300; + g.setNode('a', { width: 50, height: 100 }); + g.setNode('b', { width: 75, height: 200 }); + g.setEdge('a', 'b', { width: 60, height: 70, minlen: 2, labelpos: 'c' }); + layout(g); + expect(g.edge('a', 'b').x).to.equal(75 / 2); + expect(g.edge('a', 'b').y).to.be.gt(g.node('a').y).to.be.lt(g.node('b').y); + }); + + it('can layout out a short cycle', function () { + g.graph().ranksep = 200; + g.setNode('a', { width: 100, height: 100 }); + g.setNode('b', { width: 100, height: 100 }); + g.setEdge('a', 'b', { weight: 2 }); + g.setEdge('b', 'a'); + layout(g); + expect(extractCoordinates(g)).to.eql({ + a: { x: 100 / 2, y: 100 / 2 }, + b: { x: 100 / 2, y: 100 + 200 + 100 / 2 }, + }); + // One arrow should point down, one up + expect(g.edge('a', 'b').points[1].y).gt(g.edge('a', 'b').points[0].y); + expect(g.edge('b', 'a').points[0].y).gt(g.edge('b', 'a').points[1].y); + }); + + it('adds rectangle intersects for edges', function () { + g.graph().ranksep = 200; + g.setNode('a', { width: 100, height: 100 }); + g.setNode('b', { width: 100, height: 100 }); + g.setEdge('a', 'b'); + layout(g); + var points = g.edge('a', 'b').points; + expect(points).to.have.length(3); + expect(points).eqls([ + { x: 100 / 2, y: 100 }, // intersect with bottom of a + { x: 100 / 2, y: 100 + 200 / 2 }, // point for edge label + { x: 100 / 2, y: 100 + 200 }, // intersect with top of b + ]); + }); + + it('adds rectangle intersects for edges spanning multiple ranks', function () { + g.graph().ranksep = 200; + g.setNode('a', { width: 100, height: 100 }); + g.setNode('b', { width: 100, height: 100 }); + g.setEdge('a', 'b', { minlen: 2 }); + layout(g); + var points = g.edge('a', 'b').points; + expect(points).to.have.length(5); + expect(points).eqls([ + { x: 100 / 2, y: 100 }, // intersect with bottom of a + { x: 100 / 2, y: 100 + 200 / 2 }, // bend #1 + { x: 100 / 2, y: 100 + 400 / 2 }, // point for edge label + { x: 100 / 2, y: 100 + 600 / 2 }, // bend #2 + { x: 100 / 2, y: 100 + 800 / 2 }, // intersect with top of b + ]); + }); + + describe('can layout a self loop', function () { + _.forEach(['TB', 'BT', 'LR', 'RL'], function (rankdir) { + it('in rankdir = ' + rankdir, function () { + g.graph().edgesep = 75; + g.graph().rankdir = rankdir; + g.setNode('a', { width: 100, height: 100 }); + g.setEdge('a', 'a', { width: 50, height: 50 }); + layout(g); + var nodeA = g.node('a'); + var points = g.edge('a', 'a').points; + expect(points).to.have.length(7); + _.forEach(points, function (point) { + if (rankdir !== 'LR' && rankdir !== 'RL') { + expect(point.x).gt(nodeA.x); + expect(Math.abs(point.y - nodeA.y)).lte(nodeA.height / 2); + } else { + expect(point.y).gt(nodeA.y); + expect(Math.abs(point.x - nodeA.x)).lte(nodeA.width / 2); + } + }); + }); + }); + }); + + it('can layout a graph with subgraphs', function () { + // To be expanded, this primarily ensures nothing blows up for the moment. + g.setNode('a', { width: 50, height: 50 }); + g.setParent('a', 'sg1'); + layout(g); + }); + + it('minimizes the height of subgraphs', function () { + _.forEach(['a', 'b', 'c', 'd', 'x', 'y'], function (v) { + g.setNode(v, { width: 50, height: 50 }); + }); + g.setPath(['a', 'b', 'c', 'd']); + g.setEdge('a', 'x', { weight: 100 }); + g.setEdge('y', 'd', { weight: 100 }); + g.setParent('x', 'sg'); + g.setParent('y', 'sg'); + + // We did not set up an edge (x, y), and we set up high-weight edges from + // outside of the subgraph to nodes in the subgraph. This is to try to + // force nodes x and y to be on different ranks, which we want our ranker + // to avoid. + layout(g); + expect(g.node('x').y).to.equal(g.node('y').y); + }); + + it('can layout subgraphs with different rankdirs', function () { + g.setNode('a', { width: 50, height: 50 }); + g.setNode('sg', {}); + g.setParent('a', 'sg'); + + function check(rankdir) { + expect(g.node('sg').width, 'width ' + rankdir).gt(50); + expect(g.node('sg').height, 'height ' + rankdir).gt(50); + expect(g.node('sg').x, 'x ' + rankdir).gt(50 / 2); + expect(g.node('sg').y, 'y ' + rankdir).gt(50 / 2); + } + + _.forEach(['tb', 'bt', 'lr', 'rl'], function (rankdir) { + g.graph().rankdir = rankdir; + layout(g); + check(rankdir); + }); + }); + + it('adds dimensions to the graph', function () { + g.setNode('a', { width: 100, height: 50 }); + layout(g); + expect(g.graph().width).equals(100); + expect(g.graph().height).equals(50); + }); + + describe('ensures all coordinates are in the bounding box for the graph', function () { + _.forEach(['TB', 'BT', 'LR', 'RL'], function (rankdir) { + describe(rankdir, function () { + beforeEach(function () { + g.graph().rankdir = rankdir; + }); + + it('node', function () { + g.setNode('a', { width: 100, height: 200 }); + layout(g); + expect(g.node('a').x).equals(100 / 2); + expect(g.node('a').y).equals(200 / 2); + }); + + it('edge, labelpos = l', function () { + g.setNode('a', { width: 100, height: 100 }); + g.setNode('b', { width: 100, height: 100 }); + g.setEdge('a', 'b', { + width: 1000, + height: 2000, + labelpos: 'l', + labeloffset: 0, + }); + layout(g); + if (rankdir === 'TB' || rankdir === 'BT') { + expect(g.edge('a', 'b').x).equals(1000 / 2); + } else { + expect(g.edge('a', 'b').y).equals(2000 / 2); + } + }); + }); + }); + }); + + it('treats attributes with case-insensitivity', function () { + g.graph().nodeSep = 200; // note the capital S + g.setNode('a', { width: 50, height: 100 }); + g.setNode('b', { width: 75, height: 200 }); + layout(g); + expect(extractCoordinates(g)).to.eql({ + a: { x: 50 / 2, y: 200 / 2 }, + b: { x: 50 + 200 + 75 / 2, y: 200 / 2 }, + }); + }); +}); + +function extractCoordinates(g) { + var nodes = g.nodes(); + return _.zipObject( + nodes, + _.map(nodes, function (v) { + return _.pick(g.node(v), ['x', 'y']); + }), + ); +} diff --git a/src/dagre/nesting-graph.test.js b/src/dagre/nesting-graph.test.js new file mode 100644 index 0000000..42d4f84 --- /dev/null +++ b/src/dagre/nesting-graph.test.js @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graphlib/index.js'; +import { components } from '../graphlib/alg/components.js'; +import * as nestingGraph from './nesting-graph.js'; + +describe('rank/nestingGraph', function () { + var g; + + beforeEach(function () { + g = new Graph({ compound: true }).setGraph({}).setDefaultNodeLabel(function () { + return {}; + }); + }); + + describe('run', function () { + it('connects a disconnected graph', function () { + g.setNode('a'); + g.setNode('b'); + expect(components(g)).to.have.length(2); + nestingGraph.run(g); + expect(components(g)).to.have.length(1); + expect(g.hasNode('a')); + expect(g.hasNode('b')); + }); + + it('adds border nodes to the top and bottom of a subgraph', function () { + g.setParent('a', 'sg1'); + nestingGraph.run(g); + + var borderTop = g.node('sg1').borderTop; + var borderBottom = g.node('sg1').borderBottom; + expect(borderTop).to.exist; + expect(borderBottom).to.exist; + expect(g.parent(borderTop)).to.equal('sg1'); + expect(g.parent(borderBottom)).to.equal('sg1'); + expect(g.outEdges(borderTop, 'a')).to.have.length(1); + expect(g.edge(g.outEdges(borderTop, 'a')[0]).minlen).equals(1); + expect(g.outEdges('a', borderBottom)).to.have.length(1); + expect(g.edge(g.outEdges('a', borderBottom)[0]).minlen).equals(1); + expect(g.node(borderTop)).eqls({ width: 0, height: 0, dummy: 'border' }); + expect(g.node(borderBottom)).eqls({ width: 0, height: 0, dummy: 'border' }); + }); + + it('adds edges between borders of nested subgraphs', function () { + g.setParent('sg2', 'sg1'); + g.setParent('a', 'sg2'); + nestingGraph.run(g); + + var sg1Top = g.node('sg1').borderTop; + var sg1Bottom = g.node('sg1').borderBottom; + var sg2Top = g.node('sg2').borderTop; + var sg2Bottom = g.node('sg2').borderBottom; + expect(sg1Top).to.exist; + expect(sg1Bottom).to.exist; + expect(sg2Top).to.exist; + expect(sg2Bottom).to.exist; + expect(g.outEdges(sg1Top, sg2Top)).to.have.length(1); + expect(g.edge(g.outEdges(sg1Top, sg2Top)[0]).minlen).equals(1); + expect(g.outEdges(sg2Bottom, sg1Bottom)).to.have.length(1); + expect(g.edge(g.outEdges(sg2Bottom, sg1Bottom)[0]).minlen).equals(1); + }); + + it('adds sufficient weight to border to node edges', function () { + // We want to keep subgraphs tight, so we should ensure that the weight for + // the edge between the top (and bottom) border nodes and nodes in the + // subgraph have weights exceeding anything in the graph. + g.setParent('x', 'sg'); + g.setEdge('a', 'x', { weight: 100 }); + g.setEdge('x', 'b', { weight: 200 }); + nestingGraph.run(g); + + var top = g.node('sg').borderTop; + var bot = g.node('sg').borderBottom; + expect(g.edge(top, 'x').weight).to.be.gt(300); + expect(g.edge('x', bot).weight).to.be.gt(300); + }); + + it('adds an edge from the root to the tops of top-level subgraphs', function () { + g.setParent('a', 'sg1'); + nestingGraph.run(g); + + var root = g.graph().nestingRoot; + var borderTop = g.node('sg1').borderTop; + expect(root).to.exist; + expect(borderTop).to.exist; + expect(g.outEdges(root, borderTop)).to.have.length(1); + expect(g.hasEdge(g.outEdges(root, borderTop)[0])).to.be.true; + }); + + it('adds an edge from root to each node with the correct minlen #1', function () { + g.setNode('a'); + nestingGraph.run(g); + + var root = g.graph().nestingRoot; + expect(root).to.exist; + expect(g.outEdges(root, 'a')).to.have.length(1); + expect(g.edge(g.outEdges(root, 'a')[0])).eqls({ weight: 0, minlen: 1 }); + }); + + it('adds an edge from root to each node with the correct minlen #2', function () { + g.setParent('a', 'sg1'); + nestingGraph.run(g); + + var root = g.graph().nestingRoot; + expect(root).to.exist; + expect(g.outEdges(root, 'a')).to.have.length(1); + expect(g.edge(g.outEdges(root, 'a')[0])).eqls({ weight: 0, minlen: 3 }); + }); + + it('adds an edge from root to each node with the correct minlen #3', function () { + g.setParent('sg2', 'sg1'); + g.setParent('a', 'sg2'); + nestingGraph.run(g); + + var root = g.graph().nestingRoot; + expect(root).to.exist; + expect(g.outEdges(root, 'a')).to.have.length(1); + expect(g.edge(g.outEdges(root, 'a')[0])).eqls({ weight: 0, minlen: 5 }); + }); + + it('does not add an edge from the root to itself', function () { + g.setNode('a'); + nestingGraph.run(g); + + var root = g.graph().nestingRoot; + expect(g.outEdges(root, root)).eqls([]); + }); + + it('expands inter-node edges to separate SG border and nodes #1', function () { + g.setEdge('a', 'b', { minlen: 1 }); + nestingGraph.run(g); + expect(g.edge('a', 'b').minlen).equals(1); + }); + + it('expands inter-node edges to separate SG border and nodes #2', function () { + g.setParent('a', 'sg1'); + g.setEdge('a', 'b', { minlen: 1 }); + nestingGraph.run(g); + expect(g.edge('a', 'b').minlen).equals(3); + }); + + it('expands inter-node edges to separate SG border and nodes #3', function () { + g.setParent('sg2', 'sg1'); + g.setParent('a', 'sg2'); + g.setEdge('a', 'b', { minlen: 1 }); + nestingGraph.run(g); + expect(g.edge('a', 'b').minlen).equals(5); + }); + + it('sets minlen correctly for nested SG boder to children', function () { + g.setParent('a', 'sg1'); + g.setParent('sg2', 'sg1'); + g.setParent('b', 'sg2'); + nestingGraph.run(g); + + // We expect the following layering: + // + // 0: root + // 1: empty (close sg2) + // 2: empty (close sg1) + // 3: open sg1 + // 4: open sg2 + // 5: a, b + // 6: close sg2 + // 7: close sg1 + + var root = g.graph().nestingRoot; + var sg1Top = g.node('sg1').borderTop; + var sg1Bot = g.node('sg1').borderBottom; + var sg2Top = g.node('sg2').borderTop; + var sg2Bot = g.node('sg2').borderBottom; + + expect(g.edge(root, sg1Top).minlen).equals(3); + expect(g.edge(sg1Top, sg2Top).minlen).equals(1); + expect(g.edge(sg1Top, 'a').minlen).equals(2); + expect(g.edge('a', sg1Bot).minlen).equals(2); + expect(g.edge(sg2Top, 'b').minlen).equals(1); + expect(g.edge('b', sg2Bot).minlen).equals(1); + expect(g.edge(sg2Bot, sg1Bot).minlen).equals(1); + }); + }); + + describe('cleanup', function () { + it('removes nesting graph edges', function () { + g.setParent('a', 'sg1'); + g.setEdge('a', 'b', { minlen: 1 }); + nestingGraph.run(g); + nestingGraph.cleanup(g); + expect(g.successors('a')).eqls(['b']); + }); + + it('removes the root node', function () { + g.setParent('a', 'sg1'); + nestingGraph.run(g); + nestingGraph.cleanup(g); + expect(g.nodeCount()).to.equal(4); // sg1 + sg1Top + sg1Bottom + "a" + }); + }); +}); diff --git a/src/dagre/normalize.test.js b/src/dagre/normalize.test.js new file mode 100644 index 0000000..f95ddd3 --- /dev/null +++ b/src/dagre/normalize.test.js @@ -0,0 +1,229 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import * as normalize from './normalize.js'; +import { Graph } from '../graphlib/index.js'; + +describe('normalize', function () { + var g; + + beforeEach(function () { + g = new Graph({ multigraph: true, compound: true }).setGraph({}); + }); + + describe('run', function () { + it('does not change a short edge', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 1 }); + g.setEdge('a', 'b', {}); + + normalize.run(g); + + expect(_.map(g.edges(), incidentNodes)).to.eql([{ v: 'a', w: 'b' }]); + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(1); + }); + + it('splits a two layer edge into two segments', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 2 }); + g.setEdge('a', 'b', {}); + + normalize.run(g); + + expect(g.successors('a')).to.have.length(1); + var successor = g.successors('a')[0]; + expect(g.node(successor).dummy).to.equal('edge'); + expect(g.node(successor).rank).to.equal(1); + expect(g.successors(successor)).to.eql(['b']); + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(2); + + expect(g.graph().dummyChains).to.have.length(1); + expect(g.graph().dummyChains[0]).to.equal(successor); + }); + + it('assigns width = 0, height = 0 to dummy nodes by default', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 2 }); + g.setEdge('a', 'b', { width: 10, height: 10 }); + + normalize.run(g); + + expect(g.successors('a')).to.have.length(1); + var successor = g.successors('a')[0]; + expect(g.node(successor).width).to.equal(0); + expect(g.node(successor).height).to.equal(0); + }); + + it('assigns width and height from the edge for the node on labelRank', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 4 }); + g.setEdge('a', 'b', { width: 20, height: 10, labelRank: 2 }); + + normalize.run(g); + + var labelV = g.successors(g.successors('a')[0])[0]; + var labelNode = g.node(labelV); + expect(labelNode.width).to.equal(20); + expect(labelNode.height).to.equal(10); + }); + + it('preserves the weight for the edge', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 2 }); + g.setEdge('a', 'b', { weight: 2 }); + + normalize.run(g); + + expect(g.successors('a')).to.have.length(1); + expect(g.edge('a', g.successors('a')[0]).weight).to.equal(2); + }); + }); + + describe('undo', function () { + it('reverses the run operation', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 2 }); + g.setEdge('a', 'b', {}); + + normalize.run(g); + normalize.undo(g); + + expect(_.map(g.edges(), incidentNodes)).to.eql([{ v: 'a', w: 'b' }]); + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(2); + }); + + it('restores previous edge labels', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 2 }); + g.setEdge('a', 'b', { foo: 'bar' }); + + normalize.run(g); + normalize.undo(g); + + expect(g.edge('a', 'b').foo).equals('bar'); + }); + + it("collects assigned coordinates into the 'points' attribute", function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 2 }); + g.setEdge('a', 'b', {}); + + normalize.run(g); + + var dummyLabel = g.node(g.neighbors('a')[0]); + dummyLabel.x = 5; + dummyLabel.y = 10; + + normalize.undo(g); + + expect(g.edge('a', 'b').points).eqls([{ x: 5, y: 10 }]); + }); + + it("merges assigned coordinates into the 'points' attribute", function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 4 }); + g.setEdge('a', 'b', {}); + + normalize.run(g); + + var aSucLabel = g.node(g.neighbors('a')[0]); + aSucLabel.x = 5; + aSucLabel.y = 10; + + var midLabel = g.node(g.successors(g.successors('a')[0])[0]); + midLabel.x = 20; + midLabel.y = 25; + + var bPredLabel = g.node(g.neighbors('b')[0]); + bPredLabel.x = 100; + bPredLabel.y = 200; + + normalize.undo(g); + + expect(g.edge('a', 'b').points).eqls([ + { x: 5, y: 10 }, + { x: 20, y: 25 }, + { x: 100, y: 200 }, + ]); + }); + + it('sets coords and dims for the label, if the edge has one', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 2 }); + g.setEdge('a', 'b', { width: 10, height: 20, labelRank: 1 }); + + normalize.run(g); + + var labelNode = g.node(g.successors('a')[0]); + labelNode.x = 50; + labelNode.y = 60; + labelNode.width = 20; + labelNode.height = 10; + + normalize.undo(g); + + expect(_.pick(g.edge('a', 'b'), ['x', 'y', 'width', 'height'])).eqls({ + x: 50, + y: 60, + width: 20, + height: 10, + }); + }); + + it('sets coords and dims for the label, if the long edge has one', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 4 }); + g.setEdge('a', 'b', { width: 10, height: 20, labelRank: 2 }); + + normalize.run(g); + + var labelNode = g.node(g.successors(g.successors('a')[0])[0]); + labelNode.x = 50; + labelNode.y = 60; + labelNode.width = 20; + labelNode.height = 10; + + normalize.undo(g); + + expect(_.pick(g.edge('a', 'b'), ['x', 'y', 'width', 'height'])).eqls({ + x: 50, + y: 60, + width: 20, + height: 10, + }); + }); + + it('restores multi-edges', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 2 }); + g.setEdge('a', 'b', {}, 'bar'); + g.setEdge('a', 'b', {}, 'foo'); + + normalize.run(g); + + var outEdges = _.sortBy(g.outEdges('a'), 'name'); + expect(outEdges).to.have.length(2); + + var barDummy = g.node(outEdges[0].w); + barDummy.x = 5; + barDummy.y = 10; + + var fooDummy = g.node(outEdges[1].w); + fooDummy.x = 15; + fooDummy.y = 20; + + normalize.undo(g); + + expect(g.hasEdge('a', 'b')).to.be.false; + expect(g.edge('a', 'b', 'bar').points).eqls([{ x: 5, y: 10 }]); + expect(g.edge('a', 'b', 'foo').points).eqls([{ x: 15, y: 20 }]); + }); + }); +}); + +function incidentNodes(edge) { + return { v: edge.v, w: edge.w }; +} diff --git a/src/dagre/order/add-subgraph-constraints.test.js b/src/dagre/order/add-subgraph-constraints.test.js new file mode 100644 index 0000000..7a2d75f --- /dev/null +++ b/src/dagre/order/add-subgraph-constraints.test.js @@ -0,0 +1,62 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../../graphlib/graph.js'; +import { addSubgraphConstraints } from './add-subgraph-constraints.js'; + +describe('order/addSubgraphConstraints', function () { + var g, cg; + + beforeEach(function () { + g = new Graph({ compound: true }); + cg = new Graph(); + }); + + it('does not change CG for a flat set of nodes', function () { + var vs = ['a', 'b', 'c', 'd']; + _.forEach(vs, function (v) { + g.setNode(v); + }); + addSubgraphConstraints(g, cg, vs); + expect(cg.nodeCount()).equals(0); + expect(cg.edgeCount()).equals(0); + }); + + it("doesn't create a constraint for contiguous subgraph nodes", function () { + var vs = ['a', 'b', 'c']; + _.forEach(vs, function (v) { + g.setParent(v, 'sg'); + }); + addSubgraphConstraints(g, cg, vs); + expect(cg.nodeCount()).equals(0); + expect(cg.edgeCount()).equals(0); + }); + + it('adds a constraint when the parents for adjacent nodes are different', function () { + var vs = ['a', 'b']; + g.setParent('a', 'sg1'); + g.setParent('b', 'sg2'); + addSubgraphConstraints(g, cg, vs); + expect(cg.edges()).eqls([{ v: 'sg1', w: 'sg2' }]); + }); + + it('works for multiple levels', function () { + var vs = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; + _.forEach(vs, function (v) { + g.setNode(v); + }); + g.setParent('b', 'sg2'); + g.setParent('sg2', 'sg1'); + g.setParent('c', 'sg1'); + g.setParent('d', 'sg3'); + g.setParent('sg3', 'sg1'); + g.setParent('f', 'sg4'); + g.setParent('g', 'sg5'); + g.setParent('sg5', 'sg4'); + addSubgraphConstraints(g, cg, vs); + expect(_.sortBy(cg.edges(), 'v')).eqls([ + { v: 'sg1', w: 'sg4' }, + { v: 'sg2', w: 'sg3' }, + ]); + }); +}); diff --git a/src/dagre/order/barycenter.test.js b/src/dagre/order/barycenter.test.js new file mode 100644 index 0000000..8e0cb4d --- /dev/null +++ b/src/dagre/order/barycenter.test.js @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { barycenter } from './barycenter.js'; +import { Graph } from '../../graphlib/graph.js'; + +describe('order/barycenter', function () { + var g; + + beforeEach(function () { + g = new Graph() + .setDefaultNodeLabel(function () { + return {}; + }) + .setDefaultEdgeLabel(function () { + return { weight: 1 }; + }); + }); + + it('assigns an undefined barycenter for a node with no predecessors', function () { + g.setNode('x', {}); + + var results = barycenter(g, ['x']); + expect(results).to.have.length(1); + expect(results[0]).to.eql({ v: 'x' }); + }); + + it('assigns the position of the sole predecessors', function () { + g.setNode('a', { order: 2 }); + g.setEdge('a', 'x'); + + var results = barycenter(g, ['x']); + expect(results).to.have.length(1); + expect(results[0]).eqls({ v: 'x', barycenter: 2, weight: 1 }); + }); + + it('assigns the average of multiple predecessors', function () { + g.setNode('a', { order: 2 }); + g.setNode('b', { order: 4 }); + g.setEdge('a', 'x'); + g.setEdge('b', 'x'); + + var results = barycenter(g, ['x']); + expect(results).to.have.length(1); + expect(results[0]).eqls({ v: 'x', barycenter: 3, weight: 2 }); + }); + + it('takes into account the weight of edges', function () { + g.setNode('a', { order: 2 }); + g.setNode('b', { order: 4 }); + g.setEdge('a', 'x', { weight: 3 }); + g.setEdge('b', 'x'); + + var results = barycenter(g, ['x']); + expect(results).to.have.length(1); + expect(results[0]).eqls({ v: 'x', barycenter: 2.5, weight: 4 }); + }); + + it('calculates barycenters for all nodes in the movable layer', function () { + g.setNode('a', { order: 1 }); + g.setNode('b', { order: 2 }); + g.setNode('c', { order: 4 }); + g.setEdge('a', 'x'); + g.setEdge('b', 'x'); + g.setNode('y'); + g.setEdge('a', 'z', { weight: 2 }); + g.setEdge('c', 'z'); + + var results = barycenter(g, ['x', 'y', 'z']); + expect(results).to.have.length(3); + expect(results[0]).eqls({ v: 'x', barycenter: 1.5, weight: 2 }); + expect(results[1]).eqls({ v: 'y' }); + expect(results[2]).eqls({ v: 'z', barycenter: 2, weight: 3 }); + }); +}); diff --git a/src/dagre/order/build-layer-graph.test.js b/src/dagre/order/build-layer-graph.test.js new file mode 100644 index 0000000..7a54278 --- /dev/null +++ b/src/dagre/order/build-layer-graph.test.js @@ -0,0 +1,120 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../../graphlib/graph.js'; +import { buildLayerGraph } from './build-layer-graph.js'; + +describe('order/buildLayerGraph', function () { + var g; + + beforeEach(function () { + g = new Graph({ compound: true, multigraph: true }); + }); + + it('places movable nodes with no parents under the root node', function () { + g.setNode('a', { rank: 1 }); + g.setNode('b', { rank: 1 }); + g.setNode('c', { rank: 2 }); + g.setNode('d', { rank: 3 }); + + var lg; + lg = buildLayerGraph(g, 1, 'inEdges'); + expect(lg.hasNode(lg.graph().root)); + expect(lg.children()).eqls([lg.graph().root]); + expect(lg.children(lg.graph().root)).eqls(['a', 'b']); + }); + + it('copies flat nodes from the layer to the graph', function () { + g.setNode('a', { rank: 1 }); + g.setNode('b', { rank: 1 }); + g.setNode('c', { rank: 2 }); + g.setNode('d', { rank: 3 }); + + expect(buildLayerGraph(g, 1, 'inEdges').nodes()).to.include('a'); + expect(buildLayerGraph(g, 1, 'inEdges').nodes()).to.include('b'); + expect(buildLayerGraph(g, 2, 'inEdges').nodes()).to.include('c'); + expect(buildLayerGraph(g, 3, 'inEdges').nodes()).to.include('d'); + }); + + it('uses the original node label for copied nodes', function () { + // This allows us to make updates to the original graph and have them + // be available automatically in the layer graph. + g.setNode('a', { foo: 1, rank: 1 }); + g.setNode('b', { foo: 2, rank: 2 }); + g.setEdge('a', 'b', { weight: 1 }); + + var lg = buildLayerGraph(g, 2, 'inEdges'); + + expect(lg.node('a').foo).equals(1); + g.node('a').foo = 'updated'; + expect(lg.node('a').foo).equals('updated'); + + expect(lg.node('b').foo).equals(2); + g.node('b').foo = 'updated'; + expect(lg.node('b').foo).equals('updated'); + }); + + it('copies edges incident on rank nodes to the graph (inEdges)', function () { + g.setNode('a', { rank: 1 }); + g.setNode('b', { rank: 1 }); + g.setNode('c', { rank: 2 }); + g.setNode('d', { rank: 3 }); + g.setEdge('a', 'c', { weight: 2 }); + g.setEdge('b', 'c', { weight: 3 }); + g.setEdge('c', 'd', { weight: 4 }); + + expect(buildLayerGraph(g, 1, 'inEdges').edgeCount()).to.equal(0); + expect(buildLayerGraph(g, 2, 'inEdges').edgeCount()).to.equal(2); + expect(buildLayerGraph(g, 2, 'inEdges').edge('a', 'c')).eqls({ weight: 2 }); + expect(buildLayerGraph(g, 2, 'inEdges').edge('b', 'c')).eqls({ weight: 3 }); + expect(buildLayerGraph(g, 3, 'inEdges').edgeCount()).to.equal(1); + expect(buildLayerGraph(g, 3, 'inEdges').edge('c', 'd')).eqls({ weight: 4 }); + }); + + it('copies edges incident on rank nodes to the graph (outEdges)', function () { + g.setNode('a', { rank: 1 }); + g.setNode('b', { rank: 1 }); + g.setNode('c', { rank: 2 }); + g.setNode('d', { rank: 3 }); + g.setEdge('a', 'c', { weight: 2 }); + g.setEdge('b', 'c', { weight: 3 }); + g.setEdge('c', 'd', { weight: 4 }); + + expect(buildLayerGraph(g, 1, 'outEdges').edgeCount()).to.equal(2); + expect(buildLayerGraph(g, 1, 'outEdges').edge('c', 'a')).eqls({ weight: 2 }); + expect(buildLayerGraph(g, 1, 'outEdges').edge('c', 'b')).eqls({ weight: 3 }); + expect(buildLayerGraph(g, 2, 'outEdges').edgeCount()).to.equal(1); + expect(buildLayerGraph(g, 2, 'outEdges').edge('d', 'c')).eqls({ weight: 4 }); + expect(buildLayerGraph(g, 3, 'outEdges').edgeCount()).to.equal(0); + }); + + it('collapses multi-edges', function () { + g.setNode('a', { rank: 1 }); + g.setNode('b', { rank: 2 }); + g.setEdge('a', 'b', { weight: 2 }); + g.setEdge('a', 'b', { weight: 3 }, 'multi'); + + expect(buildLayerGraph(g, 2, 'inEdges').edge('a', 'b')).eqls({ weight: 5 }); + }); + + it('preserves hierarchy for the movable layer', function () { + g.setNode('a', { rank: 0 }); + g.setNode('b', { rank: 0 }); + g.setNode('c', { rank: 0 }); + g.setNode('sg', { + minRank: 0, + maxRank: 0, + borderLeft: ['bl'], + borderRight: ['br'], + }); + _.forEach(['a', 'b'], function (v) { + g.setParent(v, 'sg'); + }); + + var lg = buildLayerGraph(g, 0, 'inEdges'); + var root = lg.graph().root; + expect(_.sortBy(lg.children(root))).eqls(['c', 'sg']); + expect(lg.parent('a')).equals('sg'); + expect(lg.parent('b')).equals('sg'); + }); +}); diff --git a/src/dagre/order/cross-count.test.js b/src/dagre/order/cross-count.test.js new file mode 100644 index 0000000..00b5213 --- /dev/null +++ b/src/dagre/order/cross-count.test.js @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../../graphlib/graph.js'; +import { crossCount } from './cross-count.js'; + +describe('crossCount', function () { + var g; + + beforeEach(function () { + g = new Graph().setDefaultEdgeLabel(function () { + return { weight: 1 }; + }); + }); + + it('returns 0 for an empty layering', function () { + expect(crossCount(g, [])).equals(0); + }); + + it('returns 0 for a layering with no crossings', function () { + g.setEdge('a1', 'b1'); + g.setEdge('a2', 'b2'); + expect( + crossCount(g, [ + ['a1', 'a2'], + ['b1', 'b2'], + ]), + ).equals(0); + }); + + it('returns 1 for a layering with 1 crossing', function () { + g.setEdge('a1', 'b1'); + g.setEdge('a2', 'b2'); + expect( + crossCount(g, [ + ['a1', 'a2'], + ['b2', 'b1'], + ]), + ).equals(1); + }); + + it('returns a weighted crossing count for a layering with 1 crossing', function () { + g.setEdge('a1', 'b1', { weight: 2 }); + g.setEdge('a2', 'b2', { weight: 3 }); + expect( + crossCount(g, [ + ['a1', 'a2'], + ['b2', 'b1'], + ]), + ).equals(6); + }); + + it('calculates crossings across layers', function () { + g.setPath(['a1', 'b1', 'c1']); + g.setPath(['a2', 'b2', 'c2']); + expect( + crossCount(g, [ + ['a1', 'a2'], + ['b2', 'b1'], + ['c1', 'c2'], + ]), + ).equals(2); + }); + + it('works for graph #1', function () { + g.setPath(['a', 'b', 'c']); + g.setPath(['d', 'e', 'c']); + g.setPath(['a', 'f', 'i']); + g.setEdge('a', 'e'); + expect( + crossCount(g, [ + ['a', 'd'], + ['b', 'e', 'f'], + ['c', 'i'], + ]), + ).equals(1); + expect( + crossCount(g, [ + ['d', 'a'], + ['e', 'b', 'f'], + ['c', 'i'], + ]), + ).equals(0); + }); +}); diff --git a/src/dagre/order/index.test.js b/src/dagre/order/index.test.js new file mode 100644 index 0000000..6f9ba1e --- /dev/null +++ b/src/dagre/order/index.test.js @@ -0,0 +1,61 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../../graphlib/graph.js'; +import { order } from './index.js'; +import { crossCount } from './cross-count.js'; +import { buildLayerMatrix } from '../util.js'; + +describe('order', function () { + var g; + + beforeEach(function () { + g = new Graph().setDefaultEdgeLabel({ weight: 1 }); + }); + + it('does not add crossings to a tree structure', function () { + g.setNode('a', { rank: 1 }); + _.forEach(['b', 'e'], function (v) { + g.setNode(v, { rank: 2 }); + }); + _.forEach(['c', 'd', 'f'], function (v) { + g.setNode(v, { rank: 3 }); + }); + g.setPath(['a', 'b', 'c']); + g.setEdge('b', 'd'); + g.setPath(['a', 'e', 'f']); + order(g); + var layering = buildLayerMatrix(g); + expect(crossCount(g, layering)).to.equal(0); + }); + + it('can solve a simple graph', function () { + // This graph resulted in a single crossing for previous versions of dagre. + _.forEach(['a', 'd'], function (v) { + g.setNode(v, { rank: 1 }); + }); + _.forEach(['b', 'f', 'e'], function (v) { + g.setNode(v, { rank: 2 }); + }); + _.forEach(['c', 'g'], function (v) { + g.setNode(v, { rank: 3 }); + }); + order(g); + var layering = buildLayerMatrix(g); + expect(crossCount(g, layering)).to.equal(0); + }); + + it('can minimize crossings', function () { + g.setNode('a', { rank: 1 }); + _.forEach(['b', 'e', 'g'], function (v) { + g.setNode(v, { rank: 2 }); + }); + _.forEach(['c', 'f', 'h'], function (v) { + g.setNode(v, { rank: 3 }); + }); + g.setNode('d', { rank: 4 }); + order(g); + var layering = buildLayerMatrix(g); + expect(crossCount(g, layering)).to.be.lte(1); + }); +}); diff --git a/src/dagre/order/init-order.test.js b/src/dagre/order/init-order.test.js new file mode 100644 index 0000000..b9acc50 --- /dev/null +++ b/src/dagre/order/init-order.test.js @@ -0,0 +1,51 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../../graphlib/graph.js'; +import { initOrder } from './init-order.js'; + +describe('order/initOrder', function () { + var g; + + beforeEach(function () { + g = new Graph({ compound: true }).setDefaultEdgeLabel(function () { + return { weight: 1 }; + }); + }); + + it('assigns non-overlapping orders for each rank in a tree', function () { + _.forEach({ a: 0, b: 1, c: 2, d: 2, e: 1 }, function (rank, v) { + g.setNode(v, { rank: rank }); + }); + g.setPath(['a', 'b', 'c']); + g.setEdge('b', 'd'); + g.setEdge('a', 'e'); + + var layering = initOrder(g); + expect(layering[0]).to.eql(['a']); + expect(_.sortBy(layering[1])).to.eql(['b', 'e']); + expect(_.sortBy(layering[2])).to.eql(['c', 'd']); + }); + + it('assigns non-overlapping orders for each rank in a DAG', function () { + _.forEach({ a: 0, b: 1, c: 1, d: 2 }, function (rank, v) { + g.setNode(v, { rank: rank }); + }); + g.setPath(['a', 'b', 'd']); + g.setPath(['a', 'c', 'd']); + + var layering = initOrder(g); + expect(layering[0]).to.eql(['a']); + expect(_.sortBy(layering[1])).to.eql(['b', 'c']); + expect(_.sortBy(layering[2])).to.eql(['d']); + }); + + it('does not assign an order to subgraph nodes', function () { + g.setNode('a', { rank: 0 }); + g.setNode('sg1', {}); + g.setParent('a', 'sg1'); + + var layering = initOrder(g); + expect(layering).to.eql([['a']]); + }); +}); diff --git a/src/dagre/order/resolve-conflicts.test.js b/src/dagre/order/resolve-conflicts.test.js new file mode 100644 index 0000000..c1c7d46 --- /dev/null +++ b/src/dagre/order/resolve-conflicts.test.js @@ -0,0 +1,134 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../../graphlib/graph.js'; +import { resolveConflicts } from './resolve-conflicts.js'; + +describe('order/resolveConflicts', function () { + var cg; + + beforeEach(function () { + cg = new Graph(); + }); + + it('returns back nodes unchanged when no constraints exist', function () { + var input = [ + { v: 'a', barycenter: 2, weight: 3 }, + { v: 'b', barycenter: 1, weight: 2 }, + ]; + expect(_.sortBy(resolveConflicts(input, cg), 'vs')).eqls([ + { vs: ['a'], i: 0, barycenter: 2, weight: 3 }, + { vs: ['b'], i: 1, barycenter: 1, weight: 2 }, + ]); + }); + + it('returns back nodes unchanged when no conflicts exist', function () { + var input = [ + { v: 'a', barycenter: 2, weight: 3 }, + { v: 'b', barycenter: 1, weight: 2 }, + ]; + cg.setEdge('b', 'a'); + expect(_.sortBy(resolveConflicts(input, cg), 'vs')).eqls([ + { vs: ['a'], i: 0, barycenter: 2, weight: 3 }, + { vs: ['b'], i: 1, barycenter: 1, weight: 2 }, + ]); + }); + + it('coalesces nodes when there is a conflict', function () { + var input = [ + { v: 'a', barycenter: 2, weight: 3 }, + { v: 'b', barycenter: 1, weight: 2 }, + ]; + cg.setEdge('a', 'b'); + expect(_.sortBy(resolveConflicts(input, cg), 'vs')).eqls([ + { vs: ['a', 'b'], i: 0, barycenter: (3 * 2 + 2 * 1) / (3 + 2), weight: 3 + 2 }, + ]); + }); + + it('coalesces nodes when there is a conflict #2', function () { + var input = [ + { v: 'a', barycenter: 4, weight: 1 }, + { v: 'b', barycenter: 3, weight: 1 }, + { v: 'c', barycenter: 2, weight: 1 }, + { v: 'd', barycenter: 1, weight: 1 }, + ]; + cg.setPath(['a', 'b', 'c', 'd']); + expect(_.sortBy(resolveConflicts(input, cg), 'vs')).eqls([ + { vs: ['a', 'b', 'c', 'd'], i: 0, barycenter: (4 + 3 + 2 + 1) / 4, weight: 4 }, + ]); + }); + + it('works with multiple constraints for the same target #1', function () { + var input = [ + { v: 'a', barycenter: 4, weight: 1 }, + { v: 'b', barycenter: 3, weight: 1 }, + { v: 'c', barycenter: 2, weight: 1 }, + ]; + cg.setEdge('a', 'c'); + cg.setEdge('b', 'c'); + var results = resolveConflicts(input, cg); + expect(results).to.have.length(1); + expect(_.indexOf(results[0].vs, 'c')).to.be.gt(_.indexOf(results[0].vs, 'a')); + expect(_.indexOf(results[0].vs, 'c')).to.be.gt(_.indexOf(results[0].vs, 'b')); + expect(results[0].i).equals(0); + expect(results[0].barycenter).equals((4 + 3 + 2) / 3); + expect(results[0].weight).equals(3); + }); + + it('works with multiple constraints for the same target #2', function () { + var input = [ + { v: 'a', barycenter: 4, weight: 1 }, + { v: 'b', barycenter: 3, weight: 1 }, + { v: 'c', barycenter: 2, weight: 1 }, + { v: 'd', barycenter: 1, weight: 1 }, + ]; + cg.setEdge('a', 'c'); + cg.setEdge('a', 'd'); + cg.setEdge('b', 'c'); + cg.setEdge('c', 'd'); + var results = resolveConflicts(input, cg); + expect(results).to.have.length(1); + expect(_.indexOf(results[0].vs, 'c')).to.be.gt(_.indexOf(results[0].vs, 'a')); + expect(_.indexOf(results[0].vs, 'c')).to.be.gt(_.indexOf(results[0].vs, 'b')); + expect(_.indexOf(results[0].vs, 'd')).to.be.gt(_.indexOf(results[0].vs, 'c')); + expect(results[0].i).equals(0); + expect(results[0].barycenter).equals((4 + 3 + 2 + 1) / 4); + expect(results[0].weight).equals(4); + }); + + it('does nothing to a node lacking both a barycenter and a constraint', function () { + var input = [{ v: 'a' }, { v: 'b', barycenter: 1, weight: 2 }]; + expect(_.sortBy(resolveConflicts(input, cg), 'vs')).eqls([ + { vs: ['a'], i: 0 }, + { vs: ['b'], i: 1, barycenter: 1, weight: 2 }, + ]); + }); + + it('treats a node w/o a barycenter as always violating constraints #1', function () { + var input = [{ v: 'a' }, { v: 'b', barycenter: 1, weight: 2 }]; + cg.setEdge('a', 'b'); + expect(_.sortBy(resolveConflicts(input, cg), 'vs')).eqls([ + { vs: ['a', 'b'], i: 0, barycenter: 1, weight: 2 }, + ]); + }); + + it('treats a node w/o a barycenter as always violating constraints #2', function () { + var input = [{ v: 'a' }, { v: 'b', barycenter: 1, weight: 2 }]; + cg.setEdge('b', 'a'); + expect(_.sortBy(resolveConflicts(input, cg), 'vs')).eqls([ + { vs: ['b', 'a'], i: 0, barycenter: 1, weight: 2 }, + ]); + }); + + it('ignores edges not related to entries', function () { + var input = [ + { v: 'a', barycenter: 2, weight: 3 }, + { v: 'b', barycenter: 1, weight: 2 }, + ]; + cg.setEdge('c', 'd'); + expect(_.sortBy(resolveConflicts(input, cg), 'vs')).eqls([ + { vs: ['a'], i: 0, barycenter: 2, weight: 3 }, + { vs: ['b'], i: 1, barycenter: 1, weight: 2 }, + ]); + }); +}); diff --git a/src/dagre/order/sort-subgraph.test.js b/src/dagre/order/sort-subgraph.test.js new file mode 100644 index 0000000..bcb178c --- /dev/null +++ b/src/dagre/order/sort-subgraph.test.js @@ -0,0 +1,153 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { sortSubgraph } from './sort-subgraph.js'; +import { Graph } from '../../graphlib/graph.js'; + +describe('order/sortSubgraph', function () { + var g, cg; + + beforeEach(function () { + g = new Graph({ compound: true }) + .setDefaultNodeLabel(function () { + return {}; + }) + .setDefaultEdgeLabel(function () { + return { weight: 1 }; + }); + _.forEach(_.range(5), function (v) { + g.setNode(v, { order: v }); + }); + cg = new Graph(); + }); + + it('sorts a flat subgraph based on barycenter', function () { + g.setEdge(3, 'x'); + g.setEdge(1, 'y', { weight: 2 }); + g.setEdge(4, 'y'); + _.forEach(['x', 'y'], function (v) { + g.setParent(v, 'movable'); + }); + + expect(sortSubgraph(g, 'movable', cg).vs).eqls(['y', 'x']); + }); + + it('preserves the pos of a node (y) w/o neighbors in a flat subgraph', function () { + g.setEdge(3, 'x'); + g.setNode('y'); + g.setEdge(1, 'z', { weight: 2 }); + g.setEdge(4, 'z'); + _.forEach(['x', 'y', 'z'], function (v) { + g.setParent(v, 'movable'); + }); + + expect(sortSubgraph(g, 'movable', cg).vs).eqls(['z', 'y', 'x']); + }); + + it('biases to the left without reverse bias', function () { + g.setEdge(1, 'x'); + g.setEdge(1, 'y'); + _.forEach(['x', 'y'], function (v) { + g.setParent(v, 'movable'); + }); + + expect(sortSubgraph(g, 'movable', cg).vs).eqls(['x', 'y']); + }); + + it('biases to the right with reverse bias', function () { + g.setEdge(1, 'x'); + g.setEdge(1, 'y'); + _.forEach(['x', 'y'], function (v) { + g.setParent(v, 'movable'); + }); + + expect(sortSubgraph(g, 'movable', cg, true).vs).eqls(['y', 'x']); + }); + + it('aggregates stats about the subgraph', function () { + g.setEdge(3, 'x'); + g.setEdge(1, 'y', { weight: 2 }); + g.setEdge(4, 'y'); + _.forEach(['x', 'y'], function (v) { + g.setParent(v, 'movable'); + }); + + var results = sortSubgraph(g, 'movable', cg); + expect(results.barycenter).to.equal(2.25); + expect(results.weight).to.equal(4); + }); + + it('can sort a nested subgraph with no barycenter', function () { + g.setNodes(['a', 'b', 'c']); + g.setParent('a', 'y'); + g.setParent('b', 'y'); + g.setParent('c', 'y'); + g.setEdge(0, 'x'); + g.setEdge(1, 'z'); + g.setEdge(2, 'y'); + _.forEach(['x', 'y', 'z'], function (v) { + g.setParent(v, 'movable'); + }); + + expect(sortSubgraph(g, 'movable', cg).vs).eqls(['x', 'z', 'a', 'b', 'c']); + }); + + it('can sort a nested subgraph with a barycenter', function () { + g.setNodes(['a', 'b', 'c']); + g.setParent('a', 'y'); + g.setParent('b', 'y'); + g.setParent('c', 'y'); + g.setEdge(0, 'a', { weight: 3 }); + g.setEdge(0, 'x'); + g.setEdge(1, 'z'); + g.setEdge(2, 'y'); + _.forEach(['x', 'y', 'z'], function (v) { + g.setParent(v, 'movable'); + }); + + expect(sortSubgraph(g, 'movable', cg).vs).eqls(['x', 'a', 'b', 'c', 'z']); + }); + + it('can sort a nested subgraph with no in-edges', function () { + g.setNodes(['a', 'b', 'c']); + g.setParent('a', 'y'); + g.setParent('b', 'y'); + g.setParent('c', 'y'); + g.setEdge(0, 'a'); + g.setEdge(1, 'b'); + g.setEdge(0, 'x'); + g.setEdge(1, 'z'); + _.forEach(['x', 'y', 'z'], function (v) { + g.setParent(v, 'movable'); + }); + + expect(sortSubgraph(g, 'movable', cg).vs).eqls(['x', 'a', 'b', 'c', 'z']); + }); + + it('sorts border nodes to the extremes of the subgraph', function () { + g.setEdge(0, 'x'); + g.setEdge(1, 'y'); + g.setEdge(2, 'z'); + g.setNode('sg1', { borderLeft: 'bl', borderRight: 'br' }); + _.forEach(['x', 'y', 'z', 'bl', 'br'], function (v) { + g.setParent(v, 'sg1'); + }); + expect(sortSubgraph(g, 'sg1', cg).vs).eqls(['bl', 'x', 'y', 'z', 'br']); + }); + + it('assigns a barycenter to a subgraph based on previous border nodes', function () { + g.setNode('bl1', { order: 0 }); + g.setNode('br1', { order: 1 }); + g.setEdge('bl1', 'bl2'); + g.setEdge('br1', 'br2'); + _.forEach(['bl2', 'br2'], function (v) { + g.setParent(v, 'sg'); + }); + g.setNode('sg', { borderLeft: 'bl2', borderRight: 'br2' }); + expect(sortSubgraph(g, 'sg', cg)).eqls({ + barycenter: 0.5, + weight: 2, + vs: ['bl2', 'br2'], + }); + }); +}); diff --git a/src/dagre/order/sort.test.js b/src/dagre/order/sort.test.js new file mode 100644 index 0000000..dc80d1c --- /dev/null +++ b/src/dagre/order/sort.test.js @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import { sort } from './sort.js'; + +describe('sort', function () { + it('sorts nodes by barycenter', function () { + var input = [ + { vs: ['a'], i: 0, barycenter: 2, weight: 3 }, + { vs: ['b'], i: 1, barycenter: 1, weight: 2 }, + ]; + expect(sort(input)).eqls({ + vs: ['b', 'a'], + barycenter: (2 * 3 + 1 * 2) / (3 + 2), + weight: 3 + 2, + }); + }); + + it('can sort super-nodes', function () { + var input = [ + { vs: ['a', 'c', 'd'], i: 0, barycenter: 2, weight: 3 }, + { vs: ['b'], i: 1, barycenter: 1, weight: 2 }, + ]; + expect(sort(input)).eqls({ + vs: ['b', 'a', 'c', 'd'], + barycenter: (2 * 3 + 1 * 2) / (3 + 2), + weight: 3 + 2, + }); + }); + + it('biases to the left by default', function () { + var input = [ + { vs: ['a'], i: 0, barycenter: 1, weight: 1 }, + { vs: ['b'], i: 1, barycenter: 1, weight: 1 }, + ]; + expect(sort(input)).eqls({ + vs: ['a', 'b'], + barycenter: 1, + weight: 2, + }); + }); + + it('biases to the right if biasRight = true', function () { + var input = [ + { vs: ['a'], i: 0, barycenter: 1, weight: 1 }, + { vs: ['b'], i: 1, barycenter: 1, weight: 1 }, + ]; + expect(sort(input, true)).eqls({ + vs: ['b', 'a'], + barycenter: 1, + weight: 2, + }); + }); + + it('can sort nodes without a barycenter', function () { + var input = [ + { vs: ['a'], i: 0, barycenter: 2, weight: 1 }, + { vs: ['b'], i: 1, barycenter: 6, weight: 1 }, + { vs: ['c'], i: 2 }, + { vs: ['d'], i: 3, barycenter: 3, weight: 1 }, + ]; + expect(sort(input)).eqls({ + vs: ['a', 'd', 'c', 'b'], + barycenter: (2 + 6 + 3) / 3, + weight: 3, + }); + }); + + it('can handle no barycenters for any nodes', function () { + var input = [ + { vs: ['a'], i: 0 }, + { vs: ['b'], i: 3 }, + { vs: ['c'], i: 2 }, + { vs: ['d'], i: 1 }, + ]; + expect(sort(input)).eqls({ vs: ['a', 'd', 'c', 'b'] }); + }); + + it('can handle a barycenter of 0', function () { + var input = [ + { vs: ['a'], i: 0, barycenter: 0, weight: 1 }, + { vs: ['b'], i: 3 }, + { vs: ['c'], i: 2 }, + { vs: ['d'], i: 1 }, + ]; + expect(sort(input)).eqls({ + vs: ['a', 'd', 'c', 'b'], + barycenter: 0, + weight: 1, + }); + }); +}); diff --git a/src/dagre/parent-dummy-chains.test.js b/src/dagre/parent-dummy-chains.test.js new file mode 100644 index 0000000..fea98f4 --- /dev/null +++ b/src/dagre/parent-dummy-chains.test.js @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graphlib/index.js'; +import { parentDummyChains } from './parent-dummy-chains.js'; + +describe('parentDummyChains', function () { + var g; + + beforeEach(function () { + g = new Graph({ compound: true }).setGraph({}); + }); + + it('does not set a parent if both the tail and head have no parent', function () { + g.setNode('a'); + g.setNode('b'); + g.setNode('d1', { edgeObj: { v: 'a', w: 'b' } }); + g.graph().dummyChains = ['d1']; + g.setPath(['a', 'd1', 'b']); + + parentDummyChains(g); + expect(g.parent('d1')).to.be.undefined; + }); + + it("uses the tail's parent for the first node if it is not the root", function () { + g.setParent('a', 'sg1'); + g.setNode('sg1', { minRank: 0, maxRank: 2 }); + g.setNode('d1', { edgeObj: { v: 'a', w: 'b' }, rank: 2 }); + g.graph().dummyChains = ['d1']; + g.setPath(['a', 'd1', 'b']); + + parentDummyChains(g); + expect(g.parent('d1')).equals('sg1'); + }); + + it("uses the heads's parent for the first node if tail's is root", function () { + g.setParent('b', 'sg1'); + g.setNode('sg1', { minRank: 1, maxRank: 3 }); + g.setNode('d1', { edgeObj: { v: 'a', w: 'b' }, rank: 1 }); + g.graph().dummyChains = ['d1']; + g.setPath(['a', 'd1', 'b']); + + parentDummyChains(g); + expect(g.parent('d1')).equals('sg1'); + }); + + it('handles a long chain starting in a subgraph', function () { + g.setParent('a', 'sg1'); + g.setNode('sg1', { minRank: 0, maxRank: 2 }); + g.setNode('d1', { edgeObj: { v: 'a', w: 'b' }, rank: 2 }); + g.setNode('d2', { rank: 3 }); + g.setNode('d3', { rank: 4 }); + g.graph().dummyChains = ['d1']; + g.setPath(['a', 'd1', 'd2', 'd3', 'b']); + + parentDummyChains(g); + expect(g.parent('d1')).equals('sg1'); + expect(g.parent('d2')).to.be.undefined; + expect(g.parent('d3')).to.be.undefined; + }); + + it('handles a long chain ending in a subgraph', function () { + g.setParent('b', 'sg1'); + g.setNode('sg1', { minRank: 3, maxRank: 5 }); + g.setNode('d1', { edgeObj: { v: 'a', w: 'b' }, rank: 1 }); + g.setNode('d2', { rank: 2 }); + g.setNode('d3', { rank: 3 }); + g.graph().dummyChains = ['d1']; + g.setPath(['a', 'd1', 'd2', 'd3', 'b']); + + parentDummyChains(g); + expect(g.parent('d1')).to.be.undefined; + expect(g.parent('d2')).to.be.undefined; + expect(g.parent('d3')).equals('sg1'); + }); + + it('handles nested subgraphs', function () { + g.setParent('a', 'sg2'); + g.setParent('sg2', 'sg1'); + g.setNode('sg1', { minRank: 0, maxRank: 4 }); + g.setNode('sg2', { minRank: 1, maxRank: 3 }); + g.setParent('b', 'sg4'); + g.setParent('sg4', 'sg3'); + g.setNode('sg3', { minRank: 6, maxRank: 10 }); + g.setNode('sg4', { minRank: 7, maxRank: 9 }); + for (var i = 0; i < 5; ++i) { + g.setNode('d' + (i + 1), { rank: i + 3 }); + } + g.node('d1').edgeObj = { v: 'a', w: 'b' }; + g.graph().dummyChains = ['d1']; + g.setPath(['a', 'd1', 'd2', 'd3', 'd4', 'd5', 'b']); + + parentDummyChains(g); + expect(g.parent('d1')).equals('sg2'); + expect(g.parent('d2')).equals('sg1'); + expect(g.parent('d3')).to.be.undefined; + expect(g.parent('d4')).equals('sg3'); + expect(g.parent('d5')).equals('sg4'); + }); + + it('handles overlapping rank ranges', function () { + g.setParent('a', 'sg1'); + g.setNode('sg1', { minRank: 0, maxRank: 3 }); + g.setParent('b', 'sg2'); + g.setNode('sg2', { minRank: 2, maxRank: 6 }); + g.setNode('d1', { edgeObj: { v: 'a', w: 'b' }, rank: 2 }); + g.setNode('d2', { rank: 3 }); + g.setNode('d3', { rank: 4 }); + g.graph().dummyChains = ['d1']; + g.setPath(['a', 'd1', 'd2', 'd3', 'b']); + + parentDummyChains(g); + expect(g.parent('d1')).equals('sg1'); + expect(g.parent('d2')).equals('sg1'); + expect(g.parent('d3')).equals('sg2'); + }); + + it('handles an LCA that is not the root of the graph #1', function () { + g.setParent('a', 'sg1'); + g.setParent('sg2', 'sg1'); + g.setNode('sg1', { minRank: 0, maxRank: 6 }); + g.setParent('b', 'sg2'); + g.setNode('sg2', { minRank: 3, maxRank: 5 }); + g.setNode('d1', { edgeObj: { v: 'a', w: 'b' }, rank: 2 }); + g.setNode('d2', { rank: 3 }); + g.graph().dummyChains = ['d1']; + g.setPath(['a', 'd1', 'd2', 'b']); + + parentDummyChains(g); + expect(g.parent('d1')).equals('sg1'); + expect(g.parent('d2')).equals('sg2'); + }); + + it('handles an LCA that is not the root of the graph #2', function () { + g.setParent('a', 'sg2'); + g.setParent('sg2', 'sg1'); + g.setNode('sg1', { minRank: 0, maxRank: 6 }); + g.setParent('b', 'sg1'); + g.setNode('sg2', { minRank: 1, maxRank: 3 }); + g.setNode('d1', { edgeObj: { v: 'a', w: 'b' }, rank: 3 }); + g.setNode('d2', { rank: 4 }); + g.graph().dummyChains = ['d1']; + g.setPath(['a', 'd1', 'd2', 'b']); + + parentDummyChains(g); + expect(g.parent('d1')).equals('sg2'); + expect(g.parent('d2')).equals('sg1'); + }); +}); diff --git a/src/dagre/position/bk.test.js b/src/dagre/position/bk.test.js new file mode 100644 index 0000000..0964d69 --- /dev/null +++ b/src/dagre/position/bk.test.js @@ -0,0 +1,678 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { buildLayerMatrix } from '../util.js'; +import { + findType1Conflicts, + findType2Conflicts, + addConflict, + hasConflict, + verticalAlignment, + horizontalCompaction, + alignCoordinates, + balance, + findSmallestWidthAlignment, + positionX, +} from './bk.js'; +import { Graph } from '../../graphlib/graph.js'; + +describe('position/bk', function () { + var g; + + beforeEach(function () { + g = new Graph().setGraph({}); + }); + + describe('findType1Conflicts', function () { + var layering; + + beforeEach(function () { + g.setDefaultEdgeLabel(function () { + return {}; + }) + .setNode('a', { rank: 0, order: 0 }) + .setNode('b', { rank: 0, order: 1 }) + .setNode('c', { rank: 1, order: 0 }) + .setNode('d', { rank: 1, order: 1 }) + // Set up crossing + .setEdge('a', 'd') + .setEdge('b', 'c'); + + layering = buildLayerMatrix(g); + }); + + it('does not mark edges that have no conflict', function () { + g.removeEdge('a', 'd'); + g.removeEdge('b', 'c'); + g.setEdge('a', 'c'); + g.setEdge('b', 'd'); + + var conflicts = findType1Conflicts(g, layering); + expect(hasConflict(conflicts, 'a', 'c')).to.be.false; + expect(hasConflict(conflicts, 'b', 'd')).to.be.false; + }); + + it('does not mark type-0 conflicts (no dummies)', function () { + var conflicts = findType1Conflicts(g, layering); + expect(hasConflict(conflicts, 'a', 'd')).to.be.false; + expect(hasConflict(conflicts, 'b', 'c')).to.be.false; + }); + + _.forEach(['a', 'b', 'c', 'd'], function (v) { + it('does not mark type-0 conflicts (' + v + ' is dummy)', function () { + g.node(v).dummy = true; + + var conflicts = findType1Conflicts(g, layering); + expect(hasConflict(conflicts, 'a', 'd')).to.be.false; + expect(hasConflict(conflicts, 'b', 'c')).to.be.false; + }); + }); + + _.forEach(['a', 'b', 'c', 'd'], function (v) { + it('does mark type-1 conflicts (' + v + ' is non-dummy)', function () { + _.forEach(['a', 'b', 'c', 'd'], function (w) { + if (v !== w) { + g.node(w).dummy = true; + } + }); + + var conflicts = findType1Conflicts(g, layering); + if (v === 'a' || v === 'd') { + expect(hasConflict(conflicts, 'a', 'd')).to.be.true; + expect(hasConflict(conflicts, 'b', 'c')).to.be.false; + } else { + expect(hasConflict(conflicts, 'a', 'd')).to.be.false; + expect(hasConflict(conflicts, 'b', 'c')).to.be.true; + } + }); + }); + + it('does not mark type-2 conflicts (all dummies)', function () { + _.forEach(['a', 'b', 'c', 'd'], function (v) { + g.node(v).dummy = true; + }); + + var conflicts = findType1Conflicts(g, layering); + expect(hasConflict(conflicts, 'a', 'd')).to.be.false; + expect(hasConflict(conflicts, 'b', 'c')).to.be.false; + findType1Conflicts(g, layering); + }); + }); + + describe('findType2Conflicts', function () { + var layering; + + beforeEach(function () { + g.setDefaultEdgeLabel(function () { + return {}; + }) + .setNode('a', { rank: 0, order: 0 }) + .setNode('b', { rank: 0, order: 1 }) + .setNode('c', { rank: 1, order: 0 }) + .setNode('d', { rank: 1, order: 1 }) + // Set up crossing + .setEdge('a', 'd') + .setEdge('b', 'c'); + + layering = buildLayerMatrix(g); + }); + + it('marks type-2 conflicts favoring border segments #1', function () { + _.forEach(['a', 'd'], function (v) { + g.node(v).dummy = true; + }); + + _.forEach(['b', 'c'], function (v) { + g.node(v).dummy = 'border'; + }); + + var conflicts = findType2Conflicts(g, layering); + expect(hasConflict(conflicts, 'a', 'd')).to.be.true; + expect(hasConflict(conflicts, 'b', 'c')).to.be.false; + findType1Conflicts(g, layering); + }); + + it('marks type-2 conflicts favoring border segments #2', function () { + _.forEach(['b', 'c'], function (v) { + g.node(v).dummy = true; + }); + + _.forEach(['a', 'd'], function (v) { + g.node(v).dummy = 'border'; + }); + + var conflicts = findType2Conflicts(g, layering); + expect(hasConflict(conflicts, 'a', 'd')).to.be.false; + expect(hasConflict(conflicts, 'b', 'c')).to.be.true; + findType1Conflicts(g, layering); + }); + }); + + describe('hasConflict', function () { + it('can test for a type-1 conflict regardless of edge orientation', function () { + var conflicts = {}; + addConflict(conflicts, 'b', 'a'); + expect(hasConflict(conflicts, 'a', 'b')).to.be.true; + expect(hasConflict(conflicts, 'b', 'a')).to.be.true; + }); + + it('works for multiple conflicts with the same node', function () { + var conflicts = {}; + addConflict(conflicts, 'a', 'b'); + addConflict(conflicts, 'a', 'c'); + expect(hasConflict(conflicts, 'a', 'b')).to.be.true; + expect(hasConflict(conflicts, 'a', 'c')).to.be.true; + }); + }); + + describe('verticalAlignment', function () { + it('Aligns with itself if the node has no adjacencies', function () { + g.setNode('a', { rank: 0, order: 0 }); + g.setNode('b', { rank: 1, order: 0 }); + + var layering = buildLayerMatrix(g); + var conflicts = {}; + + var result = verticalAlignment(g, layering, conflicts, g.predecessors.bind(g)); + expect(result).to.eql({ + root: { a: 'a', b: 'b' }, + align: { a: 'a', b: 'b' }, + }); + }); + + it('Aligns with its sole adjacency', function () { + g.setNode('a', { rank: 0, order: 0 }); + g.setNode('b', { rank: 1, order: 0 }); + g.setEdge('a', 'b'); + + var layering = buildLayerMatrix(g); + var conflicts = {}; + + var result = verticalAlignment(g, layering, conflicts, g.predecessors.bind(g)); + expect(result).to.eql({ + root: { a: 'a', b: 'a' }, + align: { a: 'b', b: 'a' }, + }); + }); + + it('aligns with its left median when possible', function () { + g.setNode('a', { rank: 0, order: 0 }); + g.setNode('b', { rank: 0, order: 1 }); + g.setNode('c', { rank: 1, order: 0 }); + g.setEdge('a', 'c'); + g.setEdge('b', 'c'); + + var layering = buildLayerMatrix(g); + var conflicts = {}; + + var result = verticalAlignment(g, layering, conflicts, g.predecessors.bind(g)); + expect(result).to.eql({ + root: { a: 'a', b: 'b', c: 'a' }, + align: { a: 'c', b: 'b', c: 'a' }, + }); + }); + + it('aligns correctly even regardless of node name / insertion order', function () { + // This test ensures that we're actually properly sorting nodes by + // position when searching for candidates. Many of these tests previously + // passed because the node insertion order matched the order of the nodes + // in the layering. + g.setNode('b', { rank: 0, order: 1 }); + g.setNode('c', { rank: 1, order: 0 }); + g.setNode('z', { rank: 0, order: 0 }); + g.setEdge('z', 'c'); + g.setEdge('b', 'c'); + + var layering = buildLayerMatrix(g); + var conflicts = {}; + + var result = verticalAlignment(g, layering, conflicts, g.predecessors.bind(g)); + expect(result).to.eql({ + root: { z: 'z', b: 'b', c: 'z' }, + align: { z: 'c', b: 'b', c: 'z' }, + }); + }); + + it('aligns with its right median when left is unavailable', function () { + g.setNode('a', { rank: 0, order: 0 }); + g.setNode('b', { rank: 0, order: 1 }); + g.setNode('c', { rank: 1, order: 0 }); + g.setEdge('a', 'c'); + g.setEdge('b', 'c'); + + var layering = buildLayerMatrix(g); + var conflicts = {}; + + addConflict(conflicts, 'a', 'c'); + + var result = verticalAlignment(g, layering, conflicts, g.predecessors.bind(g)); + expect(result).to.eql({ + root: { a: 'a', b: 'b', c: 'b' }, + align: { a: 'a', b: 'c', c: 'b' }, + }); + }); + + it('aligns with neither median if both are unavailable', function () { + g.setNode('a', { rank: 0, order: 0 }); + g.setNode('b', { rank: 0, order: 1 }); + g.setNode('c', { rank: 1, order: 0 }); + g.setNode('d', { rank: 1, order: 1 }); + g.setEdge('a', 'd'); + g.setEdge('b', 'c'); + g.setEdge('b', 'd'); + + var layering = buildLayerMatrix(g); + var conflicts = {}; + + var result = verticalAlignment(g, layering, conflicts, g.predecessors.bind(g)); + // c will align with b, so d will not be able to align with a, because + // (a,d) and (c,b) cross. + expect(result).to.eql({ + root: { a: 'a', b: 'b', c: 'b', d: 'd' }, + align: { a: 'a', b: 'c', c: 'b', d: 'd' }, + }); + }); + + it('aligns with the single median for an odd number of adjacencies', function () { + g.setNode('a', { rank: 0, order: 0 }); + g.setNode('b', { rank: 0, order: 1 }); + g.setNode('c', { rank: 0, order: 2 }); + g.setNode('d', { rank: 1, order: 0 }); + g.setEdge('a', 'd'); + g.setEdge('b', 'd'); + g.setEdge('c', 'd'); + + var layering = buildLayerMatrix(g); + var conflicts = {}; + + var result = verticalAlignment(g, layering, conflicts, g.predecessors.bind(g)); + expect(result).to.eql({ + root: { a: 'a', b: 'b', c: 'c', d: 'b' }, + align: { a: 'a', b: 'd', c: 'c', d: 'b' }, + }); + }); + + it('aligns blocks across multiple layers', function () { + g.setNode('a', { rank: 0, order: 0 }); + g.setNode('b', { rank: 1, order: 0 }); + g.setNode('c', { rank: 1, order: 1 }); + g.setNode('d', { rank: 2, order: 0 }); + g.setPath(['a', 'b', 'd']); + g.setPath(['a', 'c', 'd']); + + var layering = buildLayerMatrix(g); + var conflicts = {}; + + var result = verticalAlignment(g, layering, conflicts, g.predecessors.bind(g)); + expect(result).to.eql({ + root: { a: 'a', b: 'a', c: 'c', d: 'a' }, + align: { a: 'b', b: 'd', c: 'c', d: 'a' }, + }); + }); + }); + + describe('horizonalCompaction', function () { + it('places the center of a single node graph at origin (0,0)', function () { + var root = { a: 'a' }; + var align = { a: 'a' }; + g.setNode('a', { rank: 0, order: 0 }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(0); + }); + + it('separates adjacent nodes by specified node separation', function () { + var root = { a: 'a', b: 'b' }; + var align = { a: 'a', b: 'b' }; + g.graph().nodesep = 100; + g.setNode('a', { rank: 0, order: 0, width: 100 }); + g.setNode('b', { rank: 0, order: 1, width: 200 }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(0); + expect(xs.b).to.equal(100 / 2 + 100 + 200 / 2); + }); + + it('separates adjacent edges by specified node separation', function () { + var root = { a: 'a', b: 'b' }; + var align = { a: 'a', b: 'b' }; + g.graph().edgesep = 20; + g.setNode('a', { rank: 0, order: 0, width: 100, dummy: true }); + g.setNode('b', { rank: 0, order: 1, width: 200, dummy: true }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(0); + expect(xs.b).to.equal(100 / 2 + 20 + 200 / 2); + }); + + it('aligns the centers of nodes in the same block', function () { + var root = { a: 'a', b: 'a' }; + var align = { a: 'b', b: 'a' }; + g.setNode('a', { rank: 0, order: 0, width: 100 }); + g.setNode('b', { rank: 1, order: 0, width: 200 }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(0); + expect(xs.b).to.equal(0); + }); + + it('separates blocks with the appropriate separation', function () { + var root = { a: 'a', b: 'a', c: 'c' }; + var align = { a: 'b', b: 'a', c: 'c' }; + g.graph().nodesep = 75; + g.setNode('a', { rank: 0, order: 0, width: 100 }); + g.setNode('b', { rank: 1, order: 1, width: 200 }); + g.setNode('c', { rank: 1, order: 0, width: 50 }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(50 / 2 + 75 + 200 / 2); + expect(xs.b).to.equal(50 / 2 + 75 + 200 / 2); + expect(xs.c).to.equal(0); + }); + + it('separates classes with the appropriate separation', function () { + var root = { a: 'a', b: 'b', c: 'c', d: 'b' }; + var align = { a: 'a', b: 'd', c: 'c', d: 'b' }; + g.graph().nodesep = 75; + g.setNode('a', { rank: 0, order: 0, width: 100 }); + g.setNode('b', { rank: 0, order: 1, width: 200 }); + g.setNode('c', { rank: 1, order: 0, width: 50 }); + g.setNode('d', { rank: 1, order: 1, width: 80 }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(0); + expect(xs.b).to.equal(100 / 2 + 75 + 200 / 2); + expect(xs.c).to.equal(100 / 2 + 75 + 200 / 2 - 80 / 2 - 75 - 50 / 2); + expect(xs.d).to.equal(100 / 2 + 75 + 200 / 2); + }); + + it('shifts classes by max sep from the adjacent block #1', function () { + var root = { a: 'a', b: 'b', c: 'a', d: 'b' }; + var align = { a: 'c', b: 'd', c: 'a', d: 'b' }; + g.graph().nodesep = 75; + g.setNode('a', { rank: 0, order: 0, width: 50 }); + g.setNode('b', { rank: 0, order: 1, width: 150 }); + g.setNode('c', { rank: 1, order: 0, width: 60 }); + g.setNode('d', { rank: 1, order: 1, width: 70 }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(0); + expect(xs.b).to.equal(50 / 2 + 75 + 150 / 2); + expect(xs.c).to.equal(0); + expect(xs.d).to.equal(50 / 2 + 75 + 150 / 2); + }); + + it('shifts classes by max sep from the adjacent block #2', function () { + var root = { a: 'a', b: 'b', c: 'a', d: 'b' }; + var align = { a: 'c', b: 'd', c: 'a', d: 'b' }; + g.graph().nodesep = 75; + g.setNode('a', { rank: 0, order: 0, width: 50 }); + g.setNode('b', { rank: 0, order: 1, width: 70 }); + g.setNode('c', { rank: 1, order: 0, width: 60 }); + g.setNode('d', { rank: 1, order: 1, width: 150 }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(0); + expect(xs.b).to.equal(60 / 2 + 75 + 150 / 2); + expect(xs.c).to.equal(0); + expect(xs.d).to.equal(60 / 2 + 75 + 150 / 2); + }); + + it('cascades class shift', function () { + var root = { a: 'a', b: 'b', c: 'c', d: 'd', e: 'b', f: 'f', g: 'd' }; + var align = { a: 'a', b: 'e', c: 'c', d: 'g', e: 'b', f: 'f', g: 'd' }; + g.graph().nodesep = 75; + g.setNode('a', { rank: 0, order: 0, width: 50 }); + g.setNode('b', { rank: 0, order: 1, width: 50 }); + g.setNode('c', { rank: 1, order: 0, width: 50 }); + g.setNode('d', { rank: 1, order: 1, width: 50 }); + g.setNode('e', { rank: 1, order: 2, width: 50 }); + g.setNode('f', { rank: 2, order: 0, width: 50 }); + g.setNode('g', { rank: 2, order: 1, width: 50 }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + + // Use f as 0, everything is relative to it + expect(xs.a).to.equal(xs.b - 50 / 2 - 75 - 50 / 2); + expect(xs.b).to.equal(xs.e); + expect(xs.c).to.equal(xs.f); + expect(xs.d).to.equal(xs.c + 50 / 2 + 75 + 50 / 2); + expect(xs.e).to.equal(xs.d + 50 / 2 + 75 + 50 / 2); + expect(xs.g).to.equal(xs.f + 50 / 2 + 75 + 50 / 2); + }); + + it('handles labelpos = l', function () { + var root = { a: 'a', b: 'b', c: 'c' }; + var align = { a: 'a', b: 'b', c: 'c' }; + g.graph().edgesep = 50; + g.setNode('a', { rank: 0, order: 0, width: 100, dummy: 'edge' }); + g.setNode('b', { + rank: 0, + order: 1, + width: 200, + dummy: 'edge-label', + labelpos: 'l', + }); + g.setNode('c', { rank: 0, order: 2, width: 300, dummy: 'edge' }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(0); + expect(xs.b).to.equal(xs.a + 100 / 2 + 50 + 200); + expect(xs.c).to.equal(xs.b + 0 + 50 + 300 / 2); + }); + + it('handles labelpos = c', function () { + var root = { a: 'a', b: 'b', c: 'c' }; + var align = { a: 'a', b: 'b', c: 'c' }; + g.graph().edgesep = 50; + g.setNode('a', { rank: 0, order: 0, width: 100, dummy: 'edge' }); + g.setNode('b', { + rank: 0, + order: 1, + width: 200, + dummy: 'edge-label', + labelpos: 'c', + }); + g.setNode('c', { rank: 0, order: 2, width: 300, dummy: 'edge' }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(0); + expect(xs.b).to.equal(xs.a + 100 / 2 + 50 + 200 / 2); + expect(xs.c).to.equal(xs.b + 200 / 2 + 50 + 300 / 2); + }); + + it('handles labelpos = r', function () { + var root = { a: 'a', b: 'b', c: 'c' }; + var align = { a: 'a', b: 'b', c: 'c' }; + g.graph().edgesep = 50; + g.setNode('a', { rank: 0, order: 0, width: 100, dummy: 'edge' }); + g.setNode('b', { + rank: 0, + order: 1, + width: 200, + dummy: 'edge-label', + labelpos: 'r', + }); + g.setNode('c', { rank: 0, order: 2, width: 300, dummy: 'edge' }); + + var xs = horizontalCompaction(g, buildLayerMatrix(g), root, align); + expect(xs.a).to.equal(0); + expect(xs.b).to.equal(xs.a + 100 / 2 + 50 + 0); + expect(xs.c).to.equal(xs.b + 200 + 50 + 300 / 2); + }); + }); + + describe('alignCoordinates', function () { + it('aligns a single node', function () { + var xss = { + ul: { a: 50 }, + ur: { a: 100 }, + dl: { a: 50 }, + dr: { a: 200 }, + }; + + alignCoordinates(xss, xss.ul); + + expect(xss.ul).to.eql({ a: 50 }); + expect(xss.ur).to.eql({ a: 50 }); + expect(xss.dl).to.eql({ a: 50 }); + expect(xss.dr).to.eql({ a: 50 }); + }); + + it('aligns multiple nodes', function () { + var xss = { + ul: { a: 50, b: 1000 }, + ur: { a: 100, b: 900 }, + dl: { a: 150, b: 800 }, + dr: { a: 200, b: 700 }, + }; + + alignCoordinates(xss, xss.ul); + + expect(xss.ul).to.eql({ a: 50, b: 1000 }); + expect(xss.ur).to.eql({ a: 200, b: 1000 }); + expect(xss.dl).to.eql({ a: 50, b: 700 }); + expect(xss.dr).to.eql({ a: 500, b: 1000 }); + }); + }); + + describe('findSmallestWidthAlignment', function () { + it('finds the alignment with the smallest width', function () { + g.setNode('a', { width: 50 }); + g.setNode('b', { width: 50 }); + + var xss = { + ul: { a: 0, b: 1000 }, + ur: { a: -5, b: 1000 }, + dl: { a: 5, b: 2000 }, + dr: { a: 0, b: 200 }, + }; + + expect(findSmallestWidthAlignment(g, xss)).to.eql(xss.dr); + }); + + it('takes node width into account', function () { + g.setNode('a', { width: 50 }); + g.setNode('b', { width: 50 }); + g.setNode('c', { width: 200 }); + + var xss = { + ul: { a: 0, b: 100, c: 75 }, + ur: { a: 0, b: 100, c: 80 }, + dl: { a: 0, b: 100, c: 85 }, + dr: { a: 0, b: 100, c: 90 }, + }; + + expect(findSmallestWidthAlignment(g, xss)).to.eql(xss.ul); + }); + }); + + describe('balance', function () { + it('aligns a single node to the shared median value', function () { + var xss = { + ul: { a: 0 }, + ur: { a: 100 }, + dl: { a: 100 }, + dr: { a: 200 }, + }; + + expect(balance(xss)).to.eql({ a: 100 }); + }); + + it('aligns a single node to the average of different median values', function () { + var xss = { + ul: { a: 0 }, + ur: { a: 75 }, + dl: { a: 125 }, + dr: { a: 200 }, + }; + + expect(balance(xss)).to.eql({ a: 100 }); + }); + + it('balances multiple nodes', function () { + var xss = { + ul: { a: 0, b: 50 }, + ur: { a: 75, b: 0 }, + dl: { a: 125, b: 60 }, + dr: { a: 200, b: 75 }, + }; + + expect(balance(xss)).to.eql({ a: 100, b: 55 }); + }); + }); + + describe('positionX', function () { + it('positions a single node at origin', function () { + g.setNode('a', { rank: 0, order: 0, width: 100 }); + expect(positionX(g)).to.eql({ a: 0 }); + }); + + it('positions a single node block at origin', function () { + g.setNode('a', { rank: 0, order: 0, width: 100 }); + g.setNode('b', { rank: 1, order: 0, width: 100 }); + g.setEdge('a', 'b'); + expect(positionX(g)).to.eql({ a: 0, b: 0 }); + }); + + it('positions a single node block at origin even when their sizes differ', function () { + g.setNode('a', { rank: 0, order: 0, width: 40 }); + g.setNode('b', { rank: 1, order: 0, width: 500 }); + g.setNode('c', { rank: 2, order: 0, width: 20 }); + g.setPath(['a', 'b', 'c']); + expect(positionX(g)).to.eql({ a: 0, b: 0, c: 0 }); + }); + + it('centers a node if it is a predecessor of two same sized nodes', function () { + g.graph().nodesep = 10; + g.setNode('a', { rank: 0, order: 0, width: 20 }); + g.setNode('b', { rank: 1, order: 0, width: 50 }); + g.setNode('c', { rank: 1, order: 1, width: 50 }); + g.setEdge('a', 'b'); + g.setEdge('a', 'c'); + + var pos = positionX(g); + var a = pos.a; + expect(pos).to.eql({ a: a, b: a - (25 + 5), c: a + (25 + 5) }); + }); + + it('shifts blocks on both sides of aligned block', function () { + g.graph().nodesep = 10; + g.setNode('a', { rank: 0, order: 0, width: 50 }); + g.setNode('b', { rank: 0, order: 1, width: 60 }); + g.setNode('c', { rank: 1, order: 0, width: 70 }); + g.setNode('d', { rank: 1, order: 1, width: 80 }); + g.setEdge('b', 'c'); + + var pos = positionX(g); + var b = pos.b; + var c = b; + expect(pos).to.eql({ + a: b - 60 / 2 - 10 - 50 / 2, + b: b, + c: c, + d: c + 70 / 2 + 10 + 80 / 2, + }); + }); + + it('aligns inner segments', function () { + g.graph().nodesep = 10; + g.setNode('a', { rank: 0, order: 0, width: 50, dummy: true }); + g.setNode('b', { rank: 0, order: 1, width: 60 }); + g.setNode('c', { rank: 1, order: 0, width: 70 }); + g.setNode('d', { rank: 1, order: 1, width: 80, dummy: true }); + g.setEdge('b', 'c'); + g.setEdge('a', 'd'); + + var pos = positionX(g); + var a = pos.a; + var d = a; + expect(pos).to.eql({ + a: a, + b: a + 50 / 2 + 10 + 60 / 2, + c: d - 70 / 2 - 10 - 80 / 2, + d: d, + }); + }); + }); +}); diff --git a/src/dagre/position/index.test.js b/src/dagre/position/index.test.js new file mode 100644 index 0000000..0b84079 --- /dev/null +++ b/src/dagre/position/index.test.js @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { position } from './index.js'; +import { Graph } from '../../graphlib/index.js'; + +describe('position', function () { + var g; + + beforeEach(function () { + g = new Graph({ compound: true }).setGraph({ + ranksep: 50, + nodesep: 50, + edgesep: 10, + }); + }); + + it('respects ranksep', function () { + g.graph().ranksep = 1000; + g.setNode('a', { width: 50, height: 100, rank: 0, order: 0 }); + g.setNode('b', { width: 50, height: 80, rank: 1, order: 0 }); + g.setEdge('a', 'b'); + position(g); + expect(g.node('b').y).to.equal(100 + 1000 + 80 / 2); + }); + + it('use the largest height in each rank with ranksep', function () { + g.graph().ranksep = 1000; + g.setNode('a', { width: 50, height: 100, rank: 0, order: 0 }); + g.setNode('b', { width: 50, height: 80, rank: 0, order: 1 }); + g.setNode('c', { width: 50, height: 90, rank: 1, order: 0 }); + g.setEdge('a', 'c'); + position(g); + expect(g.node('a').y).to.equal(100 / 2); + expect(g.node('b').y).to.equal(100 / 2); // Note we used 100 and not 80 here + expect(g.node('c').y).to.equal(100 + 1000 + 90 / 2); + }); + + it('respects nodesep', function () { + g.graph().nodesep = 1000; + g.setNode('a', { width: 50, height: 100, rank: 0, order: 0 }); + g.setNode('b', { width: 70, height: 80, rank: 0, order: 1 }); + position(g); + expect(g.node('b').x).to.equal(g.node('a').x + 50 / 2 + 1000 + 70 / 2); + }); + + it('should not try to position the subgraph node itself', function () { + g.setNode('a', { width: 50, height: 50, rank: 0, order: 0 }); + g.setNode('sg1', {}); + g.setParent('a', 'sg1'); + position(g); + expect(g.node('sg1')).to.not.have.property('x'); + expect(g.node('sg1')).to.not.have.property('y'); + }); +}); diff --git a/src/dagre/rank/feasible-tree.test.js b/src/dagre/rank/feasible-tree.test.js new file mode 100644 index 0000000..f9894ce --- /dev/null +++ b/src/dagre/rank/feasible-tree.test.js @@ -0,0 +1,53 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../../graphlib/graph.js'; +import { feasibleTree } from './feasible-tree.js'; + +describe('feasibleTree', function () { + it('creates a tree for a trivial input graph', function () { + var g = new Graph() + .setNode('a', { rank: 0 }) + .setNode('b', { rank: 1 }) + .setEdge('a', 'b', { minlen: 1 }); + + var tree = feasibleTree(g); + expect(g.node('b').rank).to.equal(g.node('a').rank + 1); + expect(tree.neighbors('a')).to.eql(['b']); + }); + + it('correctly shortens slack by pulling a node up', function () { + var g = new Graph() + .setNode('a', { rank: 0 }) + .setNode('b', { rank: 1 }) + .setNode('c', { rank: 2 }) + .setNode('d', { rank: 2 }) + .setPath(['a', 'b', 'c'], { minlen: 1 }) + .setEdge('a', 'd', { minlen: 1 }); + + var tree = feasibleTree(g); + expect(g.node('b').rank).to.eql(g.node('a').rank + 1); + expect(g.node('c').rank).to.eql(g.node('b').rank + 1); + expect(g.node('d').rank).to.eql(g.node('a').rank + 1); + expect(_.sortBy(tree.neighbors('a'))).to.eql(['b', 'd']); + expect(_.sortBy(tree.neighbors('b'))).to.eql(['a', 'c']); + expect(tree.neighbors('c')).to.eql(['b']); + expect(tree.neighbors('d')).to.eql(['a']); + }); + + it('correctly shortens slack by pulling a node down', function () { + var g = new Graph() + .setNode('a', { rank: 2 }) + .setNode('b', { rank: 0 }) + .setNode('c', { rank: 2 }) + .setEdge('b', 'a', { minlen: 1 }) + .setEdge('b', 'c', { minlen: 1 }); + + var tree = feasibleTree(g); + expect(g.node('a').rank).to.eql(g.node('b').rank + 1); + expect(g.node('c').rank).to.eql(g.node('b').rank + 1); + expect(_.sortBy(tree.neighbors('a'))).to.eql(['b']); + expect(_.sortBy(tree.neighbors('b'))).to.eql(['a', 'c']); + expect(_.sortBy(tree.neighbors('c'))).to.eql(['b']); + }); +}); diff --git a/src/dagre/rank/index.test.js b/src/dagre/rank/index.test.js new file mode 100644 index 0000000..886433c --- /dev/null +++ b/src/dagre/rank/index.test.js @@ -0,0 +1,45 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { rank } from './index.js'; +import { Graph } from '../../graphlib/graph.js'; + +describe('rank', function () { + var RANKERS = ['longest-path', 'tight-tree', 'network-simplex', 'unknown-should-still-work']; + var g; + + beforeEach(function () { + g = new Graph() + .setGraph({}) + .setDefaultNodeLabel(function () { + return {}; + }) + .setDefaultEdgeLabel(function () { + return { minlen: 1, weight: 1 }; + }) + .setPath(['a', 'b', 'c', 'd', 'h']) + .setPath(['a', 'e', 'g', 'h']) + .setPath(['a', 'f', 'g']); + }); + + _.forEach(RANKERS, function (ranker) { + describe(ranker, function () { + it('respects the minlen attribute', function () { + g.graph().ranker = ranker; + rank(g); + _.forEach(g.edges(), function (e) { + var vRank = g.node(e.v).rank; + var wRank = g.node(e.w).rank; + expect(wRank - vRank).to.be.gte(g.edge(e).minlen); + }); + }); + + it('can rank a single node graph', function () { + var g = new Graph().setGraph({}).setNode('a', {}); + g.graph().ranker = ranker; + rank(g); + expect(g.node('a').rank).to.equal(0); + }); + }); + }); +}); diff --git a/src/dagre/rank/network-simplex.test.js b/src/dagre/rank/network-simplex.test.js new file mode 100644 index 0000000..6424c91 --- /dev/null +++ b/src/dagre/rank/network-simplex.test.js @@ -0,0 +1,466 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; +import { Graph } from '../../graphlib/graph.js'; +import { networkSimplex } from './network-simplex.js'; +import { longestPath } from './util.js'; +var initLowLimValues = networkSimplex.initLowLimValues; +var initCutValues = networkSimplex.initCutValues; +var calcCutValue = networkSimplex.calcCutValue; +var leaveEdge = networkSimplex.leaveEdge; +var enterEdge = networkSimplex.enterEdge; +var exchangeEdges = networkSimplex.exchangeEdges; +import { normalizeRanks } from '../util.js'; + +describe('network simplex', function () { + var g, t, gansnerGraph, gansnerTree; + + beforeEach(function () { + g = new Graph({ multigraph: true }) + .setDefaultNodeLabel(function () { + return {}; + }) + .setDefaultEdgeLabel(function () { + return { minlen: 1, weight: 1 }; + }); + + t = new Graph({ directed: false }) + .setDefaultNodeLabel(function () { + return {}; + }) + .setDefaultEdgeLabel(function () { + return {}; + }); + + gansnerGraph = new Graph() + .setDefaultNodeLabel(function () { + return {}; + }) + .setDefaultEdgeLabel(function () { + return { minlen: 1, weight: 1 }; + }) + .setPath(['a', 'b', 'c', 'd', 'h']) + .setPath(['a', 'e', 'g', 'h']) + .setPath(['a', 'f', 'g']); + + gansnerTree = new Graph({ directed: false }) + .setDefaultNodeLabel(function () { + return {}; + }) + .setDefaultEdgeLabel(function () { + return {}; + }) + .setPath(['a', 'b', 'c', 'd', 'h', 'g', 'e']) + .setEdge('g', 'f'); + }); + + it('can assign a rank to a single node', function () { + g.setNode('a'); + ns(g); + expect(g.node('a').rank).to.equal(0); + }); + + it('can assign a rank to a 2-node connected graph', function () { + g.setEdge('a', 'b'); + ns(g); + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(1); + }); + + it('can assign ranks for a diamond', function () { + g.setPath(['a', 'b', 'd']); + g.setPath(['a', 'c', 'd']); + ns(g); + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(1); + expect(g.node('c').rank).to.equal(1); + expect(g.node('d').rank).to.equal(2); + }); + + it('uses the minlen attribute on the edge', function () { + g.setPath(['a', 'b', 'd']); + g.setEdge('a', 'c'); + g.setEdge('c', 'd', { minlen: 2 }); + ns(g); + expect(g.node('a').rank).to.equal(0); + // longest path biases towards the lowest rank it can assign. Since the + // graph has no optimization opportunities we can assume that the longest + // path ranking is used. + expect(g.node('b').rank).to.equal(2); + expect(g.node('c').rank).to.equal(1); + expect(g.node('d').rank).to.equal(3); + }); + + it('can rank the gansner graph', function () { + g = gansnerGraph; + ns(g); + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(1); + expect(g.node('c').rank).to.equal(2); + expect(g.node('d').rank).to.equal(3); + expect(g.node('h').rank).to.equal(4); + expect(g.node('e').rank).to.equal(1); + expect(g.node('f').rank).to.equal(1); + expect(g.node('g').rank).to.equal(2); + }); + + it('can handle multi-edges', function () { + g.setPath(['a', 'b', 'c', 'd']); + g.setEdge('a', 'e', { weight: 2, minlen: 1 }); + g.setEdge('e', 'd'); + g.setEdge('b', 'c', { weight: 1, minlen: 2 }, 'multi'); + ns(g); + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(1); + // b -> c has minlen = 1 and minlen = 2, so it should be 2 ranks apart. + expect(g.node('c').rank).to.equal(3); + expect(g.node('d').rank).to.equal(4); + expect(g.node('e').rank).to.equal(1); + }); + + describe('leaveEdge', function () { + it('returns undefined if there is no edge with a negative cutvalue', function () { + var tree = new Graph({ directed: false }); + tree.setEdge('a', 'b', { cutvalue: 1 }); + tree.setEdge('b', 'c', { cutvalue: 1 }); + expect(leaveEdge(tree)).to.be.undefined; + }); + + it('returns an edge if one is found with a negative cutvalue', function () { + var tree = new Graph({ directed: false }); + tree.setEdge('a', 'b', { cutvalue: 1 }); + tree.setEdge('b', 'c', { cutvalue: -1 }); + expect(leaveEdge(tree)).to.eql({ v: 'b', w: 'c' }); + }); + }); + + describe('enterEdge', function () { + it('finds an edge from the head to tail component', function () { + g.setNode('a', { rank: 0 }) + .setNode('b', { rank: 2 }) + .setNode('c', { rank: 3 }) + .setPath(['a', 'b', 'c']) + .setEdge('a', 'c'); + t.setPath(['b', 'c', 'a']); + initLowLimValues(t, 'c'); + + var f = enterEdge(t, g, { v: 'b', w: 'c' }); + expect(undirectedEdge(f)).to.eql(undirectedEdge({ v: 'a', w: 'b' })); + }); + + it('works when the root of the tree is in the tail component', function () { + g.setNode('a', { rank: 0 }) + .setNode('b', { rank: 2 }) + .setNode('c', { rank: 3 }) + .setPath(['a', 'b', 'c']) + .setEdge('a', 'c'); + t.setPath(['b', 'c', 'a']); + initLowLimValues(t, 'b'); + + var f = enterEdge(t, g, { v: 'b', w: 'c' }); + expect(undirectedEdge(f)).to.eql(undirectedEdge({ v: 'a', w: 'b' })); + }); + + it('finds the edge with the least slack', function () { + g.setNode('a', { rank: 0 }) + .setNode('b', { rank: 1 }) + .setNode('c', { rank: 3 }) + .setNode('d', { rank: 4 }) + .setEdge('a', 'd') + .setPath(['a', 'c', 'd']) + .setEdge('b', 'c'); + t.setPath(['c', 'd', 'a', 'b']); + initLowLimValues(t, 'a'); + + var f = enterEdge(t, g, { v: 'c', w: 'd' }); + expect(undirectedEdge(f)).to.eql(undirectedEdge({ v: 'b', w: 'c' })); + }); + + it('finds an appropriate edge for gansner graph #1', function () { + g = gansnerGraph; + t = gansnerTree; + longestPath(g); + initLowLimValues(t, 'a'); + + var f = enterEdge(t, g, { v: 'g', w: 'h' }); + expect(undirectedEdge(f).v).to.equal('a'); + expect(['e', 'f']).to.include(undirectedEdge(f).w); + }); + + it('finds an appropriate edge for gansner graph #2', function () { + g = gansnerGraph; + t = gansnerTree; + longestPath(g); + initLowLimValues(t, 'e'); + + var f = enterEdge(t, g, { v: 'g', w: 'h' }); + expect(undirectedEdge(f).v).to.equal('a'); + expect(['e', 'f']).to.include(undirectedEdge(f).w); + }); + + it('finds an appropriate edge for gansner graph #3', function () { + g = gansnerGraph; + t = gansnerTree; + longestPath(g); + initLowLimValues(t, 'a'); + + var f = enterEdge(t, g, { v: 'h', w: 'g' }); + expect(undirectedEdge(f).v).to.equal('a'); + expect(['e', 'f']).to.include(undirectedEdge(f).w); + }); + + it('finds an appropriate edge for gansner graph #4', function () { + g = gansnerGraph; + t = gansnerTree; + longestPath(g); + initLowLimValues(t, 'e'); + + var f = enterEdge(t, g, { v: 'h', w: 'g' }); + expect(undirectedEdge(f).v).to.equal('a'); + expect(['e', 'f']).to.include(undirectedEdge(f).w); + }); + }); + + describe('initLowLimValues', function () { + it('assigns low, lim, and parent for each node in a tree', function () { + var g = new Graph() + .setDefaultNodeLabel(function () { + return {}; + }) + .setNodes(['a', 'b', 'c', 'd', 'e']) + .setPath(['a', 'b', 'a', 'c', 'd', 'c', 'e']); + + initLowLimValues(g, 'a'); + + var a = g.node('a'); + var b = g.node('b'); + var c = g.node('c'); + var d = g.node('d'); + var e = g.node('e'); + + expect( + _.sortBy( + _.map(g.nodes(), function (v) { + return g.node(v).lim; + }), + ), + ).to.eql(_.range(1, 6)); + + expect(a).to.eql({ low: 1, lim: 5 }); + + expect(b.parent).to.equal('a'); + expect(b.lim).to.be.lt(a.lim); + + expect(c.parent).to.equal('a'); + expect(c.lim).to.be.lt(a.lim); + expect(c.lim).to.not.equal(b.lim); + + expect(d.parent).to.equal('c'); + expect(d.lim).to.be.lt(c.lim); + + expect(e.parent).to.equal('c'); + expect(e.lim).to.be.lt(c.lim); + expect(e.lim).to.not.equal(d.lim); + }); + }); + + describe('exchangeEdges', function () { + it('exchanges edges and updates cut values and low/lim numbers', function () { + g = gansnerGraph; + t = gansnerTree; + longestPath(g); + initLowLimValues(t); + + exchangeEdges(t, g, { v: 'g', w: 'h' }, { v: 'a', w: 'e' }); + + // check new cut values + expect(t.edge('a', 'b').cutvalue).to.equal(2); + expect(t.edge('b', 'c').cutvalue).to.equal(2); + expect(t.edge('c', 'd').cutvalue).to.equal(2); + expect(t.edge('d', 'h').cutvalue).to.equal(2); + expect(t.edge('a', 'e').cutvalue).to.equal(1); + expect(t.edge('e', 'g').cutvalue).to.equal(1); + expect(t.edge('g', 'f').cutvalue).to.equal(0); + + // ensure lim numbers look right + var lims = _.sortBy( + _.map(t.nodes(), function (v) { + return t.node(v).lim; + }), + ); + expect(lims).to.eql(_.range(1, 9)); + }); + + it('updates ranks', function () { + g = gansnerGraph; + t = gansnerTree; + longestPath(g); + initLowLimValues(t); + + exchangeEdges(t, g, { v: 'g', w: 'h' }, { v: 'a', w: 'e' }); + normalizeRanks(g); + + // check new ranks + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(1); + expect(g.node('c').rank).to.equal(2); + expect(g.node('d').rank).to.equal(3); + expect(g.node('e').rank).to.equal(1); + expect(g.node('f').rank).to.equal(1); + expect(g.node('g').rank).to.equal(2); + expect(g.node('h').rank).to.equal(4); + }); + }); + + // Note: we use p for parent, c for child, gc_x for grandchild nodes, and o for + // other nodes in the tree for these tests. + describe('calcCutValue', function () { + it('works for a 2-node tree with c -> p', function () { + g.setPath(['c', 'p']); + t.setPath(['p', 'c']); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(1); + }); + + it('works for a 2-node tree with c <- p', function () { + g.setPath(['p', 'c']); + t.setPath(['p', 'c']); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(1); + }); + + it('works for 3-node tree with gc -> c -> p', function () { + g.setPath(['gc', 'c', 'p']); + t.setEdge('gc', 'c', { cutvalue: 3 }).setEdge('p', 'c'); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(3); + }); + + it('works for 3-node tree with gc -> c <- p', function () { + g.setEdge('p', 'c').setEdge('gc', 'c'); + t.setEdge('gc', 'c', { cutvalue: 3 }).setEdge('p', 'c'); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(-1); + }); + + it('works for 3-node tree with gc <- c -> p', function () { + g.setEdge('c', 'p').setEdge('c', 'gc'); + t.setEdge('gc', 'c', { cutvalue: 3 }).setEdge('p', 'c'); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(-1); + }); + + it('works for 3-node tree with gc <- c <- p', function () { + g.setPath(['p', 'c', 'gc']); + t.setEdge('gc', 'c', { cutvalue: 3 }).setEdge('p', 'c'); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(3); + }); + + it('works for 4-node tree with gc -> c -> p -> o, with o -> c', function () { + g.setEdge('o', 'c', { weight: 7 }).setPath(['gc', 'c', 'p', 'o']); + t.setEdge('gc', 'c', { cutvalue: 3 }).setPath(['c', 'p', 'o']); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(-4); + }); + + it('works for 4-node tree with gc -> c -> p -> o, with o <- c', function () { + g.setEdge('c', 'o', { weight: 7 }).setPath(['gc', 'c', 'p', 'o']); + t.setEdge('gc', 'c', { cutvalue: 3 }).setPath(['c', 'p', 'o']); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(10); + }); + + it('works for 4-node tree with o -> gc -> c -> p, with o -> c', function () { + g.setEdge('o', 'c', { weight: 7 }).setPath(['o', 'gc', 'c', 'p']); + t.setEdge('o', 'gc').setEdge('gc', 'c', { cutvalue: 3 }).setEdge('c', 'p'); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(-4); + }); + + it('works for 4-node tree with o -> gc -> c -> p, with o <- c', function () { + g.setEdge('c', 'o', { weight: 7 }).setPath(['o', 'gc', 'c', 'p']); + t.setEdge('o', 'gc').setEdge('gc', 'c', { cutvalue: 3 }).setEdge('c', 'p'); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(10); + }); + + it('works for 4-node tree with gc -> c <- p -> o, with o -> c', function () { + g.setEdge('gc', 'c').setEdge('p', 'c').setEdge('p', 'o').setEdge('o', 'c', { weight: 7 }); + t.setEdge('o', 'gc').setEdge('gc', 'c', { cutvalue: 3 }).setEdge('c', 'p'); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(6); + }); + + it('works for 4-node tree with gc -> c <- p -> o, with o <- c', function () { + g.setEdge('gc', 'c').setEdge('p', 'c').setEdge('p', 'o').setEdge('c', 'o', { weight: 7 }); + t.setEdge('o', 'gc').setEdge('gc', 'c', { cutvalue: 3 }).setEdge('c', 'p'); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(-8); + }); + + it('works for 4-node tree with o -> gc -> c <- p, with o -> c', function () { + g.setEdge('o', 'c', { weight: 7 }).setPath(['o', 'gc', 'c']).setEdge('p', 'c'); + t.setEdge('o', 'gc').setEdge('gc', 'c', { cutvalue: 3 }).setEdge('c', 'p'); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(6); + }); + + it('works for 4-node tree with o -> gc -> c <- p, with o <- c', function () { + g.setEdge('c', 'o', { weight: 7 }).setPath(['o', 'gc', 'c']).setEdge('p', 'c'); + t.setEdge('o', 'gc').setEdge('gc', 'c', { cutvalue: 3 }).setEdge('c', 'p'); + initLowLimValues(t, 'p'); + + expect(calcCutValue(t, g, 'c')).to.equal(-8); + }); + }); + + describe('initCutValues', function () { + it('works for gansnerGraph', function () { + initLowLimValues(gansnerTree); + initCutValues(gansnerTree, gansnerGraph); + expect(gansnerTree.edge('a', 'b').cutvalue).to.equal(3); + expect(gansnerTree.edge('b', 'c').cutvalue).to.equal(3); + expect(gansnerTree.edge('c', 'd').cutvalue).to.equal(3); + expect(gansnerTree.edge('d', 'h').cutvalue).to.equal(3); + expect(gansnerTree.edge('g', 'h').cutvalue).to.equal(-1); + expect(gansnerTree.edge('e', 'g').cutvalue).to.equal(0); + expect(gansnerTree.edge('f', 'g').cutvalue).to.equal(0); + }); + + it('works for updated gansnerGraph', function () { + gansnerTree.removeEdge('g', 'h'); + gansnerTree.setEdge('a', 'e'); + initLowLimValues(gansnerTree); + initCutValues(gansnerTree, gansnerGraph); + expect(gansnerTree.edge('a', 'b').cutvalue).to.equal(2); + expect(gansnerTree.edge('b', 'c').cutvalue).to.equal(2); + expect(gansnerTree.edge('c', 'd').cutvalue).to.equal(2); + expect(gansnerTree.edge('d', 'h').cutvalue).to.equal(2); + expect(gansnerTree.edge('a', 'e').cutvalue).to.equal(1); + expect(gansnerTree.edge('e', 'g').cutvalue).to.equal(1); + expect(gansnerTree.edge('f', 'g').cutvalue).to.equal(0); + }); + }); +}); + +function ns(g) { + networkSimplex(g); + normalizeRanks(g); +} + +function undirectedEdge(e) { + return e.v < e.w ? { v: e.v, w: e.w } : { v: e.w, w: e.v }; +} diff --git a/src/dagre/rank/util.test.js b/src/dagre/rank/util.test.js new file mode 100644 index 0000000..8018b04 --- /dev/null +++ b/src/dagre/rank/util.test.js @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../../graphlib/graph.js'; +import { normalizeRanks } from '../util.js'; +import { longestPath } from './util.js'; + +describe('rank/util', function () { + describe('longestPath', function () { + var g; + + beforeEach(function () { + g = new Graph() + .setDefaultNodeLabel(function () { + return {}; + }) + .setDefaultEdgeLabel(function () { + return { minlen: 1 }; + }); + }); + + it('can assign a rank to a single node graph', function () { + g.setNode('a'); + longestPath(g); + normalizeRanks(g); + expect(g.node('a').rank).to.equal(0); + }); + + it('can assign ranks to unconnected nodes', function () { + g.setNode('a'); + g.setNode('b'); + longestPath(g); + normalizeRanks(g); + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(0); + }); + + it('can assign ranks to connected nodes', function () { + g.setEdge('a', 'b'); + longestPath(g); + normalizeRanks(g); + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(1); + }); + + it('can assign ranks for a diamond', function () { + g.setPath(['a', 'b', 'd']); + g.setPath(['a', 'c', 'd']); + longestPath(g); + normalizeRanks(g); + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(1); + expect(g.node('c').rank).to.equal(1); + expect(g.node('d').rank).to.equal(2); + }); + + it('uses the minlen attribute on the edge', function () { + g.setPath(['a', 'b', 'd']); + g.setEdge('a', 'c'); + g.setEdge('c', 'd', { minlen: 2 }); + longestPath(g); + normalizeRanks(g); + expect(g.node('a').rank).to.equal(0); + // longest path biases towards the lowest rank it can assign + expect(g.node('b').rank).to.equal(2); + expect(g.node('c').rank).to.equal(1); + expect(g.node('d').rank).to.equal(3); + }); + }); +}); diff --git a/src/dagre/util.test.js b/src/dagre/util.test.js new file mode 100644 index 0000000..118ecc9 --- /dev/null +++ b/src/dagre/util.test.js @@ -0,0 +1,287 @@ +import * as _ from 'lodash-es'; +import { describe, expect, it } from 'vitest'; +import { Graph } from '../graphlib/index.js'; +import * as util from './util.js'; + +describe('util', function () { + describe('simplify', function () { + var g; + + beforeEach(function () { + g = new Graph({ multigraph: true }); + }); + + it('copies without change a graph with no multi-edges', function () { + g.setEdge('a', 'b', { weight: 1, minlen: 1 }); + var g2 = util.simplify(g); + expect(g2.edge('a', 'b')).eql({ weight: 1, minlen: 1 }); + expect(g2.edgeCount()).equals(1); + }); + + it('collapses multi-edges', function () { + g.setEdge('a', 'b', { weight: 1, minlen: 1 }); + g.setEdge('a', 'b', { weight: 2, minlen: 2 }, 'multi'); + var g2 = util.simplify(g); + expect(g2.isMultigraph()).to.be.false; + expect(g2.edge('a', 'b')).eql({ weight: 3, minlen: 2 }); + expect(g2.edgeCount()).equals(1); + }); + + it('copies the graph object', function () { + g.setGraph({ foo: 'bar' }); + var g2 = util.simplify(g); + expect(g2.graph()).eqls({ foo: 'bar' }); + }); + }); + + describe('asNonCompoundGraph', function () { + var g; + + beforeEach(function () { + g = new Graph({ compound: true, multigraph: true }); + }); + + it('copies all nodes', function () { + g.setNode('a', { foo: 'bar' }); + g.setNode('b'); + var g2 = util.asNonCompoundGraph(g); + expect(g2.node('a')).to.eql({ foo: 'bar' }); + expect(g2.hasNode('b')).to.be.true; + }); + + it('copies all edges', function () { + g.setEdge('a', 'b', { foo: 'bar' }); + g.setEdge('a', 'b', { foo: 'baz' }, 'multi'); + var g2 = util.asNonCompoundGraph(g); + expect(g2.edge('a', 'b')).eqls({ foo: 'bar' }); + expect(g2.edge('a', 'b', 'multi')).eqls({ foo: 'baz' }); + }); + + it('does not copy compound nodes', function () { + g.setParent('a', 'sg1'); + var g2 = util.asNonCompoundGraph(g); + expect(g2.parent(g)).to.be.undefined; + expect(g2.isCompound()).to.be.false; + }); + + it('copies the graph object', function () { + g.setGraph({ foo: 'bar' }); + var g2 = util.asNonCompoundGraph(g); + expect(g2.graph()).eqls({ foo: 'bar' }); + }); + }); + + describe('successorWeights', function () { + it('maps a node to its successors with associated weights', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b', { weight: 2 }); + g.setEdge('b', 'c', { weight: 1 }); + g.setEdge('b', 'c', { weight: 2 }, 'multi'); + g.setEdge('b', 'd', { weight: 1 }, 'multi'); + expect(util.successorWeights(g).a).to.eql({ b: 2 }); + expect(util.successorWeights(g).b).to.eql({ c: 3, d: 1 }); + expect(util.successorWeights(g).c).to.eql({}); + expect(util.successorWeights(g).d).to.eql({}); + }); + }); + + describe('predecessorWeights', function () { + it('maps a node to its predecessors with associated weights', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b', { weight: 2 }); + g.setEdge('b', 'c', { weight: 1 }); + g.setEdge('b', 'c', { weight: 2 }, 'multi'); + g.setEdge('b', 'd', { weight: 1 }, 'multi'); + expect(util.predecessorWeights(g).a).to.eql({}); + expect(util.predecessorWeights(g).b).to.eql({ a: 2 }); + expect(util.predecessorWeights(g).c).to.eql({ b: 3 }); + expect(util.predecessorWeights(g).d).to.eql({ b: 1 }); + }); + }); + + describe('intersectRect', function () { + function expectIntersects(rect, point) { + var cross = util.intersectRect(rect, point); + if (cross.x !== point.x) { + var m = (cross.y - point.y) / (cross.x - point.x); + expect(cross.y - rect.y).equals(m * (cross.x - rect.x)); + } + } + + function expectTouchesBorder(rect, point) { + var cross = util.intersectRect(rect, point); + if (Math.abs(rect.x - cross.x) !== rect.width / 2) { + expect(Math.abs(rect.y - cross.y)).equals(rect.height / 2); + } + } + + it("creates a slope that will intersect the rectangle's center", function () { + var rect = { x: 0, y: 0, width: 1, height: 1 }; + expectIntersects(rect, { x: 2, y: 6 }); + expectIntersects(rect, { x: 2, y: -6 }); + expectIntersects(rect, { x: 6, y: 2 }); + expectIntersects(rect, { x: -6, y: 2 }); + expectIntersects(rect, { x: 5, y: 0 }); + expectIntersects(rect, { x: 0, y: 5 }); + }); + + it('touches the border of the rectangle', function () { + var rect = { x: 0, y: 0, width: 1, height: 1 }; + expectTouchesBorder(rect, { x: 2, y: 6 }); + expectTouchesBorder(rect, { x: 2, y: -6 }); + expectTouchesBorder(rect, { x: 6, y: 2 }); + expectTouchesBorder(rect, { x: -6, y: 2 }); + expectTouchesBorder(rect, { x: 5, y: 0 }); + expectTouchesBorder(rect, { x: 0, y: 5 }); + }); + + it('throws an error if the point is at the center of the rectangle', function () { + var rect = { x: 0, y: 0, width: 1, height: 1 }; + expect(function () { + util.intersectRect(rect, { x: 0, y: 0 }); + }).to.throw(); + }); + }); + + describe('buildLayerMatrix', function () { + it('creates a matrix based on rank and order of nodes in the graph', function () { + var g = new Graph(); + g.setNode('a', { rank: 0, order: 0 }); + g.setNode('b', { rank: 0, order: 1 }); + g.setNode('c', { rank: 1, order: 0 }); + g.setNode('d', { rank: 1, order: 1 }); + g.setNode('e', { rank: 2, order: 0 }); + + expect(util.buildLayerMatrix(g)).to.eql([['a', 'b'], ['c', 'd'], ['e']]); + }); + }); + + describe('time', function () { + var consoleLog; + + beforeEach(function () { + consoleLog = console.log; + }); + + afterEach(function () { + console.log = consoleLog; + }); + + it('logs timing information', function () { + var capture = []; + console.log = function () { + capture.push(_.toArray(arguments)[0]); + }; + util.time('foo', function () {}); + expect(capture.length).to.equal(1); + expect(capture[0]).to.match(/^foo time: .*ms/); + }); + + it('returns the value from the evaluated function', function () { + console.log = function () {}; + expect(util.time('foo', _.constant('bar'))).to.equal('bar'); + }); + }); + + describe('normalizeRanks', function () { + it('adjust ranks such that all are >= 0, and at least one is 0', function () { + var g = new Graph() + .setNode('a', { rank: 3 }) + .setNode('b', { rank: 2 }) + .setNode('c', { rank: 4 }); + + util.normalizeRanks(g); + + expect(g.node('a').rank).to.equal(1); + expect(g.node('b').rank).to.equal(0); + expect(g.node('c').rank).to.equal(2); + }); + + it('works for negative ranks', function () { + var g = new Graph().setNode('a', { rank: -3 }).setNode('b', { rank: -2 }); + + util.normalizeRanks(g); + + expect(g.node('a').rank).to.equal(0); + expect(g.node('b').rank).to.equal(1); + }); + + it('does not assign a rank to subgraphs', function () { + var g = new Graph({ compound: true }) + .setNode('a', { rank: 0 }) + .setNode('sg', {}) + .setParent('a', 'sg'); + + util.normalizeRanks(g); + + expect(g.node('sg')).to.not.have.property('rank'); + expect(g.node('a').rank).to.equal(0); + }); + }); + + describe('removeEmptyRanks', function () { + it('Removes border ranks without any nodes', function () { + var g = new Graph() + .setGraph({ nodeRankFactor: 4 }) + .setNode('a', { rank: 0 }) + .setNode('b', { rank: 4 }); + util.removeEmptyRanks(g); + expect(g.node('a').rank).equals(0); + expect(g.node('b').rank).equals(1); + }); + + it('Does not remove non-border ranks', function () { + var g = new Graph() + .setGraph({ nodeRankFactor: 4 }) + .setNode('a', { rank: 0 }) + .setNode('b', { rank: 8 }); + util.removeEmptyRanks(g); + expect(g.node('a').rank).equals(0); + expect(g.node('b').rank).equals(2); + }); + }); + + describe('range', () => { + it('Builds an array to the limit', () => { + const range = util.range(4); + expect(range.length).equals(4); + expect(range.reduce((acc, v) => acc + v)).equals(6); + }); + + it('Builds an array with a start', () => { + const range = util.range(2, 4); + expect(range.length).equals(2); + expect(range.reduce((acc, v) => acc + v)).equals(5); + }); + + it('Builds an array with a negative step', () => { + const range = util.range(5, -1, -1); + expect(range[0]).equals(5); + expect(range[5]).equals(0); + }); + }); + + describe('mapValues', () => { + it('Creates an object with the same keys', () => { + const users = { + fred: { user: 'fred', age: 40 }, + pebbles: { user: 'pebbles', age: 1 }, + }; + + const ages = util.mapValues(users, (user) => user.age); // as { fred: number, pebbles: number }; + expect(ages.fred).equals(40); + expect(ages.pebbles).equals(1); + }); + + it('Can take a property name', () => { + const users = { + fred: { user: 'fred', age: 40 }, + pebbles: { user: 'pebbles', age: 1 }, + }; + + const ages = util.mapValues(users, 'age'); // as { fred: number, pebbles: number }; + expect(ages.fred).equals(40); + expect(ages.pebbles).equals(1); + }); + }); +}); diff --git a/src/graphlib/alg/components.test.js b/src/graphlib/alg/components.test.js new file mode 100644 index 0000000..227458e --- /dev/null +++ b/src/graphlib/alg/components.test.js @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { components } from './components.js'; + +describe('alg.components', function () { + it('returns an empty list for an empty graph', function () { + expect(components(new Graph({ directed: false }))).to.be.empty; + }); + + it('returns singleton lists for unconnected nodes', function () { + var g = new Graph({ directed: false }); + g.setNode('a'); + g.setNode('b'); + + var result = components(g).sort((a, b) => a[0].localeCompare(b[0])); + expect(result).to.eql([['a'], ['b']]); + }); + + it('returns a list of nodes in a component', function () { + var g = new Graph({ directed: false }); + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + + var result = components(g).map((xs) => xs.sort()); + expect(result).to.eql([['a', 'b', 'c']]); + }); + + it('returns nodes connected by a neighbor relationship in a digraph', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'c', 'a']); + g.setEdge('d', 'c'); + g.setEdge('e', 'f'); + + var result = components(g) + .map((xs) => xs.sort()) + .sort((a, b) => a[0].localeCompare(b[0])); + expect(result).to.eql([ + ['a', 'b', 'c', 'd'], + ['e', 'f'], + ]); + }); +}); diff --git a/src/graphlib/alg/dijkstra-all.test.js b/src/graphlib/alg/dijkstra-all.test.js new file mode 100644 index 0000000..064eb60 --- /dev/null +++ b/src/graphlib/alg/dijkstra-all.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { dijkstraAll } from './dijkstra-all.js'; +import { allShortestPathsTests } from '../../../test/graphlib/alg/all-shortest-paths.js'; + +describe('alg.dijkstraAll', function () { + allShortestPathsTests(dijkstraAll); + + it('throws an Error if it encounters a negative edge weight', function () { + var g = new Graph(); + g.setEdge('a', 'b', 1); + g.setEdge('a', 'c', -2); + g.setEdge('b', 'd', 3); + g.setEdge('c', 'd', 3); + + expect(function () { + dijkstraAll(g, weight(g)); + }).to.throw(); + }); +}); + +function weight(g) { + return function (e) { + return g.edge(e); + }; +} diff --git a/src/graphlib/alg/dijkstra.test.js b/src/graphlib/alg/dijkstra.test.js new file mode 100644 index 0000000..5c01844 --- /dev/null +++ b/src/graphlib/alg/dijkstra.test.js @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { dijkstra } from './dijkstra.js'; + +describe('alg.dijkstra', function () { + it('assigns distance 0 for the source node', function () { + var g = new Graph(); + g.setNode('source'); + expect(dijkstra(g, 'source')).to.eql({ source: { distance: 0 } }); + }); + + it('returns Number.POSITIVE_INFINITY for unconnected nodes', function () { + var g = new Graph(); + g.setNode('a'); + g.setNode('b'); + expect(dijkstra(g, 'a')).to.eql({ + a: { distance: 0 }, + b: { distance: Number.POSITIVE_INFINITY }, + }); + }); + + it('returns the distance and path from the source node to other nodes', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'c']); + g.setEdge('b', 'd'); + expect(dijkstra(g, 'a')).to.eql({ + a: { distance: 0 }, + b: { distance: 1, predecessor: 'a' }, + c: { distance: 2, predecessor: 'b' }, + d: { distance: 2, predecessor: 'b' }, + }); + }); + + it('works for undirected graphs', function () { + var g = new Graph({ directed: false }); + g.setPath(['a', 'b', 'c']); + g.setEdge('b', 'd'); + expect(dijkstra(g, 'a')).to.eql({ + a: { distance: 0 }, + b: { distance: 1, predecessor: 'a' }, + c: { distance: 2, predecessor: 'b' }, + d: { distance: 2, predecessor: 'b' }, + }); + }); + + it('uses an optionally supplied weight function', function () { + var g = new Graph(); + g.setEdge('a', 'b', 1); + g.setEdge('a', 'c', 2); + g.setEdge('b', 'd', 3); + g.setEdge('c', 'd', 3); + + expect(dijkstra(g, 'a', weightFn(g))).to.eql({ + a: { distance: 0 }, + b: { distance: 1, predecessor: 'a' }, + c: { distance: 2, predecessor: 'a' }, + d: { distance: 4, predecessor: 'b' }, + }); + }); + + it('uses an optionally supplied edge function', function () { + var g = new Graph(); + g.setPath(['a', 'c', 'd']); + g.setEdge('b', 'c'); + + expect( + dijkstra(g, 'd', undefined, function (e) { + return g.inEdges(e); + }), + ).to.eql({ + a: { distance: 2, predecessor: 'c' }, + b: { distance: 2, predecessor: 'c' }, + c: { distance: 1, predecessor: 'd' }, + d: { distance: 0 }, + }); + }); + + it('throws an Error if it encounters a negative edge weight', function () { + var g = new Graph(); + g.setEdge('a', 'b', 1); + g.setEdge('a', 'c', -2); + g.setEdge('b', 'd', 3); + g.setEdge('c', 'd', 3); + + expect(function () { + dijkstra(g, 'a', weightFn(g)); + }).to.throw(); + }); +}); + +function weightFn(g) { + return function (e) { + return g.edge(e); + }; +} diff --git a/src/graphlib/alg/find-cycles.test.js b/src/graphlib/alg/find-cycles.test.js new file mode 100644 index 0000000..dc61015 --- /dev/null +++ b/src/graphlib/alg/find-cycles.test.js @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { findCycles } from './find-cycles.js'; + +describe('alg.findCycles', function () { + it('returns an empty array for an empty graph', function () { + expect(findCycles(new Graph())).to.eql([]); + }); + + it('returns an empty array if the graph has no cycles', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'c']); + expect(findCycles(g)).to.eql([]); + }); + + it('returns a single entry for a cycle of 1 node', function () { + var g = new Graph(); + g.setPath(['a', 'a']); + expect(sort(findCycles(g))).to.eql([['a']]); + }); + + it('returns a single entry for a cycle of 2 nodes', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'a']); + expect(sort(findCycles(g))).to.eql([['a', 'b']]); + }); + + it('returns a single entry for a triangle', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'c', 'a']); + expect(sort(findCycles(g))).to.eql([['a', 'b', 'c']]); + }); + + it('returns multiple entries for multiple cycles', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'a']); + g.setPath(['c', 'd', 'e', 'c']); + g.setPath(['f', 'g', 'g']); + g.setNode('h'); + expect(sort(findCycles(g))).to.eql([['a', 'b'], ['c', 'd', 'e'], ['g']]); + }); +}); + +// A helper that sorts components and their contents +function sort(cmpts) { + return cmpts.map((cmpt) => cmpt.sort()).sort((a, b) => a[0].localeCompare(b[0])); +} diff --git a/src/graphlib/alg/floyd-warshall.test.js b/src/graphlib/alg/floyd-warshall.test.js new file mode 100644 index 0000000..8a9fa84 --- /dev/null +++ b/src/graphlib/alg/floyd-warshall.test.js @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { floydWarshall } from './floyd-warshall.js'; +import { allShortestPathsTests } from '../../../test/graphlib/alg/all-shortest-paths.js'; + +describe('alg.floydWarshall', function () { + allShortestPathsTests(floydWarshall); + + it('handles negative weights', function () { + var g = new Graph(); + g.setEdge('a', 'b', 1); + g.setEdge('a', 'c', -2); + g.setEdge('b', 'd', 3); + g.setEdge('c', 'd', 3); + + expect(floydWarshall(g, weightFn(g))).to.eql({ + a: { + a: { distance: 0 }, + b: { distance: 1, predecessor: 'a' }, + c: { distance: -2, predecessor: 'a' }, + d: { distance: 1, predecessor: 'c' }, + }, + b: { + a: { distance: Number.POSITIVE_INFINITY }, + b: { distance: 0 }, + c: { distance: Number.POSITIVE_INFINITY }, + d: { distance: 3, predecessor: 'b' }, + }, + c: { + a: { distance: Number.POSITIVE_INFINITY }, + b: { distance: Number.POSITIVE_INFINITY }, + c: { distance: 0 }, + d: { distance: 3, predecessor: 'c' }, + }, + d: { + a: { distance: Number.POSITIVE_INFINITY }, + b: { distance: Number.POSITIVE_INFINITY }, + c: { distance: Number.POSITIVE_INFINITY }, + d: { distance: 0 }, + }, + }); + }); + + it('does include negative weight self edges', function () { + var g = new Graph(); + g.setEdge('a', 'a', -1); + + // In the case of a negative cycle the distance is not well-defined beyond + // having a negative value along the diagonal. + expect(floydWarshall(g, weightFn(g))).to.eql({ + a: { + a: { distance: -2, predecessor: 'a' }, + }, + }); + }); +}); + +function weightFn(g) { + return function (edge) { + return g.edge(edge); + }; +} diff --git a/src/graphlib/alg/is-acyclic.test.js b/src/graphlib/alg/is-acyclic.test.js new file mode 100644 index 0000000..e81af51 --- /dev/null +++ b/src/graphlib/alg/is-acyclic.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { isAcyclic } from './is-acyclic.js'; + +describe('alg.isAcyclic', function () { + it('returns true if the graph has no cycles', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'c']); + expect(isAcyclic(g)).to.be.true; + }); + + it('returns false if the graph has at least one cycle', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'c', 'a']); + expect(isAcyclic(g)).to.be.false; + }); + + it('returns false if the graph has a cycle of 1 node', function () { + var g = new Graph(); + g.setPath(['a', 'a']); + expect(isAcyclic(g)).to.be.false; + }); + + it('rethrows non-CycleException errors', function () { + expect(function () { + isAcyclic(undefined); + }).to.throw(); + }); +}); diff --git a/src/graphlib/alg/postorder.test.js b/src/graphlib/alg/postorder.test.js new file mode 100644 index 0000000..5caac3b --- /dev/null +++ b/src/graphlib/alg/postorder.test.js @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { postorder } from './postorder.js'; + +describe('alg.postorder', function () { + it('returns the root for a singleton graph', function () { + var g = new Graph(); + g.setNode('a'); + expect(postorder(g, 'a')).to.eql(['a']); + }); + + it('visits each node in the graph once', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'd', 'e']); + g.setPath(['a', 'c', 'd', 'e']); + + var nodes = postorder(g, 'a'); + expect(nodes.sort()).to.eql(['a', 'b', 'c', 'd', 'e']); + }); + + it('works for a tree', function () { + var g = new Graph(); + g.setEdge('a', 'b'); + g.setPath(['a', 'c', 'd']); + g.setEdge('c', 'e'); + + var nodes = postorder(g, 'a'); + expect(nodes.indexOf('b')).to.be.lt(nodes.indexOf('a')); + expect(nodes.indexOf('c')).to.be.lt(nodes.indexOf('a')); + expect(nodes.indexOf('d')).to.be.lt(nodes.indexOf('c')); + expect(nodes.indexOf('e')).to.be.lt(nodes.indexOf('c')); + expect(nodes.sort()).to.eql(['a', 'b', 'c', 'd', 'e']); + }); + + it('works for an array of roots', function () { + var g = new Graph(); + g.setEdge('a', 'b'); + g.setEdge('c', 'd'); + g.setNode('e'); + g.setNode('f'); + + var nodes = postorder(g, ['a', 'b', 'c', 'e']); + expect(nodes.indexOf('b')).to.be.lt(nodes.indexOf('a')); + expect(nodes.indexOf('d')).to.be.lt(nodes.indexOf('c')); + expect(nodes.sort()).to.eql(['a', 'b', 'c', 'd', 'e']); + }); + + it('works for multiple connected roots', function () { + var g = new Graph(); + g.setEdge('a', 'b'); + g.setEdge('a', 'c'); + g.setEdge('d', 'c'); + + var nodes = postorder(g, ['a', 'd']); + expect(nodes.indexOf('b')).to.be.lt(nodes.indexOf('a')); + expect(nodes.indexOf('c')).to.be.lt(nodes.indexOf('a')); + expect(nodes.indexOf('c')).to.be.lt(nodes.indexOf('d')); + expect(nodes.sort()).to.eql(['a', 'b', 'c', 'd']); + }); + + it('fails if root is not in the graph', function () { + var g = new Graph(); + g.setNode('a'); + expect(function () { + postorder(g, 'b'); + }).to.throw(); + }); +}); diff --git a/src/graphlib/alg/preorder.test.js b/src/graphlib/alg/preorder.test.js new file mode 100644 index 0000000..9019976 --- /dev/null +++ b/src/graphlib/alg/preorder.test.js @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { preorder } from './preorder.js'; + +describe('alg.preorder', function () { + it('returns the root for a singleton graph', function () { + var g = new Graph(); + g.setNode('a'); + expect(preorder(g, 'a')).to.eql(['a']); + }); + + it('visits each node in the graph once', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'd', 'e']); + g.setPath(['a', 'c', 'd', 'e']); + + var nodes = preorder(g, 'a'); + expect(nodes.sort()).to.eql(['a', 'b', 'c', 'd', 'e']); + }); + + it('works for a tree', function () { + var g = new Graph(); + g.setEdge('a', 'b'); + g.setPath(['a', 'c', 'd']); + g.setEdge('c', 'e'); + + var nodes = preorder(g, 'a'); + expect(nodes.sort()).to.eql(['a', 'b', 'c', 'd', 'e']); + expect(nodes.indexOf('b')).to.be.gt(nodes.indexOf('a')); + expect(nodes.indexOf('c')).to.be.gt(nodes.indexOf('a')); + expect(nodes.indexOf('d')).to.be.gt(nodes.indexOf('c')); + expect(nodes.indexOf('e')).to.be.gt(nodes.indexOf('c')); + }); + + it('works for an array of roots', function () { + var g = new Graph(); + g.setEdge('a', 'b'); + g.setEdge('c', 'd'); + g.setNode('e'); + g.setNode('f'); + + var nodes = preorder(g, ['a', 'c', 'e']); + expect(nodes.sort()).to.eql(['a', 'b', 'c', 'd', 'e']); + expect(nodes.indexOf('b')).to.be.gt(nodes.indexOf('a')); + expect(nodes.indexOf('d')).to.be.gt(nodes.indexOf('c')); + }); + + it('fails if root is not in the graph', function () { + var g = new Graph(); + g.setNode('a'); + expect(function () { + preorder(g, 'b'); + }).to.throw(); + }); +}); diff --git a/src/graphlib/alg/prim.test.js b/src/graphlib/alg/prim.test.js new file mode 100644 index 0000000..dc8e826 --- /dev/null +++ b/src/graphlib/alg/prim.test.js @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { prim } from './prim.js'; + +describe('alg.prim', function () { + it('returns an empty graph for an empty input', function () { + var source = new Graph(); + + var g = prim(source, weightFn(source)); + expect(g.nodeCount()).to.equal(0); + expect(g.edgeCount()).to.equal(0); + }); + + it('returns a single node graph for a graph with a single node', function () { + var source = new Graph(); + source.setNode('a'); + + var g = prim(source, weightFn(source)); + expect(g.nodes()).to.eql(['a']); + expect(g.edgeCount()).to.equal(0); + }); + + it('returns a deterministic result given an optimal solution', function () { + var source = new Graph(); + source.setEdge('a', 'b', 1); + source.setEdge('b', 'c', 2); + source.setEdge('b', 'd', 3); + // This edge should not be in the min spanning tree + source.setEdge('c', 'd', 20); + // This edge should not be in the min spanning tree + source.setEdge('c', 'e', 60); + source.setEdge('d', 'e', 1); + + var g = prim(source, weightFn(source)); + expect(g.neighbors('a').sort()).to.eql(['b']); + expect(g.neighbors('b').sort()).to.eql(['a', 'c', 'd']); + expect(g.neighbors('c').sort()).to.eql(['b']); + expect(g.neighbors('d').sort()).to.eql(['b', 'e']); + expect(g.neighbors('e').sort()).to.eql(['d']); + }); + + it('throws an Error for unconnected graphs', function () { + var source = new Graph(); + source.setNode('a'); + source.setNode('b'); + + expect(function () { + prim(source, weightFn(source)); + }).to.throw(); + }); +}); + +function weightFn(g) { + return function (edge) { + return g.edge(edge); + }; +} diff --git a/src/graphlib/alg/tarjan.test.js b/src/graphlib/alg/tarjan.test.js new file mode 100644 index 0000000..772d03a --- /dev/null +++ b/src/graphlib/alg/tarjan.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { tarjan } from './tarjan.js'; + +describe('alg.tarjan', function () { + it('returns an empty array for an empty graph', function () { + expect(tarjan(new Graph())).to.eql([]); + }); + + it('returns singletons for nodes not in a strongly connected component', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'c']); + g.setEdge('d', 'c'); + expect(sort(tarjan(g))).to.eql([['a'], ['b'], ['c'], ['d']]); + }); + + it('returns a single component for a cycle of 1 edge', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'a']); + expect(sort(tarjan(g))).to.eql([['a', 'b']]); + }); + + it('returns a single component for a triangle', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'c', 'a']); + expect(sort(tarjan(g))).to.eql([['a', 'b', 'c']]); + }); + + it('can find multiple components', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'a']); + g.setPath(['c', 'd', 'e', 'c']); + g.setNode('f'); + expect(sort(tarjan(g))).to.eql([['a', 'b'], ['c', 'd', 'e'], ['f']]); + }); +}); + +// A helper that sorts components and their contents +function sort(cmpts) { + return cmpts.map((cmpt) => cmpt.sort()).sort((a, b) => a[0].localeCompare(b[0])); +} diff --git a/src/graphlib/alg/topsort.test.js b/src/graphlib/alg/topsort.test.js new file mode 100644 index 0000000..04a3eb7 --- /dev/null +++ b/src/graphlib/alg/topsort.test.js @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../graph.js'; +import { topsort } from './topsort.js'; + +describe('alg.topsort', function () { + it('returns an empty array for an empty graph', function () { + expect(topsort(new Graph())).to.be.empty; + }); + + it('sorts nodes such that earlier nodes have directed edges to later nodes', function () { + var g = new Graph(); + g.setPath(['b', 'c', 'a']); + expect(topsort(g)).to.eql(['b', 'c', 'a']); + }); + + it('works for a diamond', function () { + var g = new Graph(); + g.setPath(['a', 'b', 'd']); + g.setPath(['a', 'c', 'd']); + + var result = topsort(g); + expect(result.indexOf('a')).to.equal(0); + expect(result.indexOf('b')).to.be.lt(result.indexOf('d')); + expect(result.indexOf('c')).to.be.lt(result.indexOf('d')); + expect(result.indexOf('d')).to.equal(3); + }); + + it('throws CycleException if there is a cycle #1', function () { + var g = new Graph(); + g.setPath(['b', 'c', 'a', 'b']); + expect(function () { + topsort(g); + }).to.throw(topsort.CycleException); + }); + + it('throws CycleException if there is a cycle #2', function () { + var g = new Graph(); + g.setPath(['b', 'c', 'a', 'b']); + g.setEdge('b', 'd'); + expect(function () { + topsort(g); + }).to.throw(topsort.CycleException); + }); + + it('throws CycleException if there is a cycle #3', function () { + var g = new Graph(); + g.setPath(['b', 'c', 'a', 'b']); + g.setNode('d'); + expect(function () { + topsort(g); + }).to.throw(topsort.CycleException); + }); +}); diff --git a/src/graphlib/data/priority-queue.test.js b/src/graphlib/data/priority-queue.test.js new file mode 100644 index 0000000..e6a82f1 --- /dev/null +++ b/src/graphlib/data/priority-queue.test.js @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; + +import { PriorityQueue } from './priority-queue.js'; + +describe('data.PriorityQueue', function () { + var pq; + + beforeEach(function () { + pq = new PriorityQueue(); + }); + + describe('size', function () { + it('returns 0 for an empty queue', function () { + expect(pq.size()).to.equal(0); + }); + + it('returns the number of elements in the queue', function () { + pq.add('a', 1); + expect(pq.size()).to.equal(1); + pq.add('b', 2); + expect(pq.size()).to.equal(2); + }); + }); + + describe('keys', function () { + it('returns all of the keys in the queue', function () { + pq.add('a', 1); + pq.add(1, 2); + pq.add(false, 3); + pq.add(undefined, 4); + pq.add(null, 5); + expect(pq.keys().sort()).to.eql(['a', '1', 'false', 'undefined', 'null'].sort()); + }); + }); + + describe('has', function () { + it('returns true if the key is in the queue', function () { + pq.add('a', 1); + expect(pq.has('a')).to.be.true; + }); + + it('returns false if the key is not in the queue', function () { + expect(pq.has('a')).to.be.false; + }); + }); + + describe('priority', function () { + it('returns the current priority for the key', function () { + pq.add('a', 1); + pq.add('b', 2); + expect(pq.priority('a')).to.equal(1); + expect(pq.priority('b')).to.equal(2); + }); + + it('returns undefined if the key is not in the queue', function () { + expect(pq.priority('foo')).to.be.undefined; + }); + }); + + describe('min', function () { + it('throws an error if there is no element in the queue', function () { + expect(function () { + pq.min(); + }).to.throw(); + }); + + it('returns the smallest element', function () { + pq.add('b', 2); + pq.add('a', 1); + expect(pq.min()).to.equal('a'); + }); + + it('does not remove the minimum element from the queue', function () { + pq.add('b', 2); + pq.add('a', 1); + pq.min(); + expect(pq.size()).to.equal(2); + }); + }); + + describe('add', function () { + it('adds the key to the queue', function () { + pq.add('a', 1); + expect(pq.keys()).to.eql(['a']); + }); + + it('returns true if the key was added', function () { + expect(pq.add('a', 1)).to.be.true; + }); + + it('returns false if the key already exists in the queue', function () { + pq.add('a', 1); + expect(pq.add('a', 1)).to.be.false; + }); + }); + + describe('removeMin', function () { + it('removes the minimum element from the queue', function () { + pq.add('b', 2); + pq.add('a', 1); + pq.add('c', 3); + pq.add('e', 5); + pq.add('d', 4); + expect(pq.removeMin()).to.equal('a'); + expect(pq.removeMin()).to.equal('b'); + expect(pq.removeMin()).to.equal('c'); + expect(pq.removeMin()).to.equal('d'); + expect(pq.removeMin()).to.equal('e'); + }); + + it('throws an error if there is no element in the queue', function () { + expect(function () { + pq.removeMin(); + }).to.throw(); + }); + }); + + describe('decrease', function () { + it('decreases the priority of a key', function () { + pq.add('a', 1); + pq.decrease('a', -1); + expect(pq.priority('a')).to.equal(-1); + }); + + it('raises an error if the key is not in the queue', function () { + expect(function () { + pq.decrease('a', -1); + }).to.throw(); + }); + + it('raises an error if the new priority is greater than current', function () { + pq.add('a', 1); + expect(function () { + pq.decrease('a', 2); + }).to.throw(); + }); + }); +}); diff --git a/src/graphlib/graph.test.js b/src/graphlib/graph.test.js new file mode 100644 index 0000000..ff83d34 --- /dev/null +++ b/src/graphlib/graph.test.js @@ -0,0 +1,1055 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from './graph.js'; + +describe('Graph', function () { + var g; + + beforeEach(function () { + g = new Graph(); + }); + + describe('initial state', function () { + it('has no nodes', function () { + expect(g.nodeCount()).to.equal(0); + }); + + it('has no edges', function () { + expect(g.edgeCount()).to.equal(0); + }); + + it('has no attributes', function () { + expect(g.graph()).to.be.undefined; + }); + + it('defaults to a simple directed graph', function () { + expect(g.isDirected()).to.be.true; + expect(g.isCompound()).to.be.false; + expect(g.isMultigraph()).to.be.false; + }); + + it('can be set to undirected', function () { + var g = new Graph({ directed: false }); + expect(g.isDirected()).to.be.false; + expect(g.isCompound()).to.be.false; + expect(g.isMultigraph()).to.be.false; + }); + + it('can be set to a compound graph', function () { + var g = new Graph({ compound: true }); + expect(g.isDirected()).to.be.true; + expect(g.isCompound()).to.be.true; + expect(g.isMultigraph()).to.be.false; + }); + + it('can be set to a mulitgraph', function () { + var g = new Graph({ multigraph: true }); + expect(g.isDirected()).to.be.true; + expect(g.isCompound()).to.be.false; + expect(g.isMultigraph()).to.be.true; + }); + }); + + describe('setGraph', function () { + it('can be used to get and set properties for the graph', function () { + g.setGraph('foo'); + expect(g.graph()).to.equal('foo'); + }); + + it('is chainable', function () { + expect(g.setGraph('foo')).to.equal(g); + }); + }); + + describe('nodes', function () { + it('is empty if there are no nodes in the graph', function () { + expect(g.nodes()).to.eql([]); + }); + + it('returns the ids of nodes in the graph', function () { + g.setNode('a'); + g.setNode('b'); + expect(g.nodes().sort()).to.eql(['a', 'b']); + }); + }); + + describe('sources', function () { + it('returns nodes in the graph that have no in-edges', function () { + g.setPath(['a', 'b', 'c']); + g.setNode('d'); + expect(g.sources().sort()).to.eql(['a', 'd']); + }); + }); + + describe('sinks', function () { + it('returns nodes in the graph that have no out-edges', function () { + g.setPath(['a', 'b', 'c']); + g.setNode('d'); + expect(g.sinks().sort()).to.eql(['c', 'd']); + }); + }); + + describe('filterNodes', function () { + it('returns an identical graph when the filter selects everything', function () { + g.setGraph('graph label'); + g.setNode('a', 123); + g.setPath(['a', 'b', 'c']); + g.setEdge('a', 'c', 456); + var g2 = g.filterNodes(function () { + return true; + }); + expect(g2.nodes().sort()).eqls(['a', 'b', 'c']); + expect(g2.successors('a').sort()).eqls(['b', 'c']); + expect(g2.successors('b').sort()).eqls(['c']); + expect(g2.node('a')).eqls(123); + expect(g2.edge('a', 'c')).eqls(456); + expect(g2.graph()).eqls('graph label'); + }); + + it('returns an empty graph when the filter selects nothing', function () { + g.setPath(['a', 'b', 'c']); + var g2 = g.filterNodes(function () { + return false; + }); + expect(g2.nodes()).eqls([]); + expect(g2.edges()).eqls([]); + }); + + it('only includes nodes for which the filter returns true', function () { + g.setNodes(['a', 'b']); + var g2 = g.filterNodes(function (v) { + return v === 'a'; + }); + expect(g2.nodes()).eqls(['a']); + }); + + it('removes edges that are connected to removed nodes', function () { + g.setEdge('a', 'b'); + var g2 = g.filterNodes(function (v) { + return v === 'a'; + }); + expect(g2.nodes().sort()).eqls(['a']); + expect(g2.edges()).eqls([]); + }); + + it('preserves the directed option', function () { + g = new Graph({ directed: true }); + expect( + g + .filterNodes(function () { + return true; + }) + .isDirected(), + ).to.be.true; + + g = new Graph({ directed: false }); + expect( + g + .filterNodes(function () { + return true; + }) + .isDirected(), + ).to.be.false; + }); + + it('preserves the multigraph option', function () { + g = new Graph({ multigraph: true }); + expect( + g + .filterNodes(function () { + return true; + }) + .isMultigraph(), + ).to.be.true; + + g = new Graph({ multigraph: false }); + expect( + g + .filterNodes(function () { + return true; + }) + .isMultigraph(), + ).to.be.false; + }); + + it('preserves the compound option', function () { + g = new Graph({ compound: true }); + expect( + g + .filterNodes(function () { + return true; + }) + .isCompound(), + ).to.be.true; + + g = new Graph({ compound: false }); + expect( + g + .filterNodes(function () { + return true; + }) + .isCompound(), + ).to.be.false; + }); + + it('includes subgraphs', function () { + g = new Graph({ compound: true }); + g.setParent('a', 'parent'); + + var g2 = g.filterNodes(function () { + return true; + }); + expect(g2.parent('a')).eqls('parent'); + }); + + it('includes multi-level subgraphs', function () { + g = new Graph({ compound: true }); + g.setParent('a', 'parent'); + g.setParent('parent', 'root'); + + var g2 = g.filterNodes(function () { + return true; + }); + expect(g2.parent('a')).eqls('parent'); + expect(g2.parent('parent')).eqls('root'); + }); + + it('promotes a node to a higher subgraph if its parent is not included', function () { + g = new Graph({ compound: true }); + g.setParent('a', 'parent'); + g.setParent('parent', 'root'); + + var g2 = g.filterNodes(function (v) { + return v !== 'parent'; + }); + expect(g2.parent('a')).eqls('root'); + }); + }); + + describe('setNodes', function () { + it('creates multiple nodes', function () { + g.setNodes(['a', 'b', 'c']); + expect(g.hasNode('a')).to.be.true; + expect(g.hasNode('b')).to.be.true; + expect(g.hasNode('c')).to.be.true; + }); + + it('can set a value for all of the nodes', function () { + g.setNodes(['a', 'b', 'c'], 'foo'); + expect(g.node('a')).to.equal('foo'); + expect(g.node('b')).to.equal('foo'); + expect(g.node('c')).to.equal('foo'); + }); + + it('is chainable', function () { + expect(g.setNodes(['a', 'b', 'c'])).to.equal(g); + }); + }); + + describe('setNode', function () { + it("creates the node if it isn't part of the graph", function () { + g.setNode('a'); + expect(g.hasNode('a')).to.be.true; + expect(g.node('a')).to.be.undefined; + expect(g.nodeCount()).to.equal(1); + }); + + it('can set a value for the node', function () { + g.setNode('a', 'foo'); + expect(g.node('a')).to.equal('foo'); + }); + + it("does not change the node's value with a 1-arg invocation", function () { + g.setNode('a', 'foo'); + g.setNode('a'); + expect(g.node('a')).to.equal('foo'); + }); + + it("can remove the node's value by passing undefined", function () { + g.setNode('a', undefined); + expect(g.node('a')).to.be.undefined; + }); + + it('is idempotent', function () { + g.setNode('a', 'foo'); + g.setNode('a', 'foo'); + expect(g.node('a')).to.equal('foo'); + expect(g.nodeCount()).to.equal(1); + }); + + it('uses the stringified form of the id', function () { + g.setNode(1); + expect(g.hasNode(1)).to.be.true; + expect(g.hasNode('1')).to.be.true; + expect(g.nodes()).eqls(['1']); + }); + + it('is chainable', function () { + expect(g.setNode('a')).to.equal(g); + }); + }); + + describe('setNodeDefaults', function () { + it('sets a default label for new nodes', function () { + g.setDefaultNodeLabel('foo'); + g.setNode('a'); + expect(g.node('a')).to.equal('foo'); + }); + + it('does not change existing nodes', function () { + g.setNode('a'); + g.setDefaultNodeLabel('foo'); + expect(g.node('a')).to.be.undefined; + }); + + it('is not used if an explicit value is set', function () { + g.setDefaultNodeLabel('foo'); + g.setNode('a', 'bar'); + expect(g.node('a')).to.equal('bar'); + }); + + it('can take a function', function () { + g.setDefaultNodeLabel(function () { + return 'foo'; + }); + g.setNode('a'); + expect(g.node('a')).to.equal('foo'); + }); + + it("can take a function that takes the node's name", function () { + g.setDefaultNodeLabel(function (v) { + return v + '-foo'; + }); + g.setNode('a'); + expect(g.node('a')).to.equal('a-foo'); + }); + + it('is chainable', function () { + expect(g.setDefaultNodeLabel('foo')).to.equal(g); + }); + }); + + describe('node', function () { + it("returns undefined if the node isn't part of the graph", function () { + expect(g.node('a')).to.be.undefined; + }); + + it('returns the value of the node if it is part of the graph', function () { + g.setNode('a', 'foo'); + expect(g.node('a')).to.equal('foo'); + }); + }); + + describe('removeNode', function () { + it('does nothing if the node is not in the graph', function () { + expect(g.nodeCount()).to.equal(0); + g.removeNode('a'); + expect(g.hasNode('a')).to.be.false; + expect(g.nodeCount()).to.equal(0); + }); + + it('removes the node if it is in the graph', function () { + g.setNode('a'); + g.removeNode('a'); + expect(g.hasNode('a')).to.be.false; + expect(g.nodeCount()).to.equal(0); + }); + + it('is idempotent', function () { + g.setNode('a'); + g.removeNode('a'); + g.removeNode('a'); + expect(g.hasNode('a')).to.be.false; + expect(g.nodeCount()).to.equal(0); + }); + + it('removes edges incident on the node', function () { + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + g.removeNode('b'); + expect(g.edgeCount()).to.equal(0); + }); + + it('removes parent / child relationships for the node', function () { + var g = new Graph({ compound: true }); + g.setParent('c', 'b'); + g.setParent('b', 'a'); + g.removeNode('b'); + expect(g.parent('b')).to.be.undefined; + expect(g.children('b')).to.be.undefined; + expect(g.children('a')).to.not.include('b'); + expect(g.parent('c')).to.be.undefined; + }); + + it('is chainable', function () { + expect(g.removeNode('a')).to.equal(g); + }); + }); + + describe('setParent', function () { + beforeEach(function () { + g = new Graph({ compound: true }); + }); + + it('throws if the graph is not compound', function () { + expect(function () { + new Graph().setParent('a', 'parent'); + }).to.throw(); + }); + + it('creates the parent if it does not exist', function () { + g.setNode('a'); + g.setParent('a', 'parent'); + expect(g.hasNode('parent')).to.be.true; + expect(g.parent('a')).to.equal('parent'); + }); + + it('creates the child if it does not exist', function () { + g.setNode('parent'); + g.setParent('a', 'parent'); + expect(g.hasNode('a')).to.be.true; + expect(g.parent('a')).to.equal('parent'); + }); + + it('has the parent as undefined if it has never been invoked', function () { + g.setNode('a'); + expect(g.parent('a')).to.be.undefined; + }); + + it('moves the node from the previous parent', function () { + g.setParent('a', 'parent'); + g.setParent('a', 'parent2'); + expect(g.parent('a')).to.equal('parent2'); + expect(g.children('parent')).to.eql([]); + expect(g.children('parent2')).to.eql(['a']); + }); + + it('removes the parent if the parent is undefined', function () { + g.setParent('a', 'parent'); + g.setParent('a', undefined); + expect(g.parent('a')).to.be.undefined; + expect(g.children().sort()).to.eql(['a', 'parent']); + }); + + it('removes the parent if no parent was specified', function () { + g.setParent('a', 'parent'); + g.setParent('a'); + expect(g.parent('a')).to.be.undefined; + expect(g.children().sort()).to.eql(['a', 'parent']); + }); + + it('is idempotent to remove a parent', function () { + g.setParent('a', 'parent'); + g.setParent('a'); + g.setParent('a'); + expect(g.parent('a')).to.be.undefined; + expect(g.children().sort()).to.eql(['a', 'parent']); + }); + + it('uses the stringified form of the id', function () { + g.setParent(2, 1); + g.setParent(3, 2); + expect(g.parent(2)).equals('1'); + expect(g.parent('2')).equals('1'); + expect(g.parent(3)).equals('2'); + }); + + it('preserves the tree invariant', function () { + g.setParent('c', 'b'); + g.setParent('b', 'a'); + expect(function () { + g.setParent('a', 'c'); + }).to.throw(); + }); + + it('is chainable', function () { + expect(g.setParent('a', 'parent')).to.equal(g); + }); + }); + + describe('parent', function () { + beforeEach(function () { + g = new Graph({ compound: true }); + }); + + it('returns undefined if the graph is not compound', function () { + expect(new Graph({ compound: false }).parent('a')).to.be.undefined; + }); + + it('returns undefined if the node is not in the graph', function () { + expect(g.parent('a')).to.be.undefined; + }); + + it('defaults to undefined for new nodes', function () { + g.setNode('a'); + expect(g.parent('a')).to.be.undefined; + }); + + it('returns the current parent assignment', function () { + g.setNode('a'); + g.setNode('parent'); + g.setParent('a', 'parent'); + expect(g.parent('a')).to.equal('parent'); + }); + }); + + describe('children', function () { + beforeEach(function () { + g = new Graph({ compound: true }); + }); + + it('returns undefined if the node is not in the graph', function () { + expect(g.children('a')).to.be.undefined; + }); + + it('defaults to en empty list for new nodes', function () { + g.setNode('a'); + expect(g.children('a')).to.eql([]); + }); + + it('returns undefined for a non-compound graph without the node', function () { + var g = new Graph(); + expect(g.children('a')).to.be.undefined; + }); + + it('returns an empty list for a non-compound graph with the node', function () { + var g = new Graph(); + g.setNode('a'); + expect(g.children('a')).eqls([]); + }); + + it('returns all nodes for the root of a non-compound graph', function () { + var g = new Graph(); + g.setNode('a'); + g.setNode('b'); + expect(g.children().sort()).eqls(['a', 'b']); + }); + + it('returns children for the node', function () { + g.setParent('a', 'parent'); + g.setParent('b', 'parent'); + expect(g.children('parent').sort()).to.eql(['a', 'b']); + }); + + it('returns all nodes without a parent when the parent is not set', function () { + g.setNode('a'); + g.setNode('b'); + g.setNode('c'); + g.setNode('parent'); + g.setParent('a', 'parent'); + expect(g.children().sort()).to.eql(['b', 'c', 'parent']); + expect(g.children(undefined).sort()).to.eql(['b', 'c', 'parent']); + }); + }); + + describe('predecessors', function () { + it('returns undefined for a node that is not in the graph', function () { + expect(g.predecessors('a')).to.be.undefined; + }); + + it('returns the predecessors of a node', function () { + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + g.setEdge('a', 'a'); + expect(g.predecessors('a').sort()).to.eql(['a']); + expect(g.predecessors('b').sort()).to.eql(['a']); + expect(g.predecessors('c').sort()).to.eql(['b']); + }); + }); + + describe('successors', function () { + it('returns undefined for a node that is not in the graph', function () { + expect(g.successors('a')).to.be.undefined; + }); + + it('returns the successors of a node', function () { + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + g.setEdge('a', 'a'); + expect(g.successors('a').sort()).to.eql(['a', 'b']); + expect(g.successors('b').sort()).to.eql(['c']); + expect(g.successors('c').sort()).to.eql([]); + }); + }); + + describe('neighbors', function () { + it('returns undefined for a node that is not in the graph', function () { + expect(g.neighbors('a')).to.be.undefined; + }); + + it('returns the neighbors of a node', function () { + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + g.setEdge('a', 'a'); + expect(g.neighbors('a').sort()).to.eql(['a', 'b']); + expect(g.neighbors('b').sort()).to.eql(['a', 'c']); + expect(g.neighbors('c').sort()).to.eql(['b']); + }); + }); + + describe('isLeaf', function () { + it('returns false for connected node in undirected graph', function () { + g = new Graph({ directed: false }); + g.setNode('a'); + g.setNode('b'); + g.setEdge('a', 'b'); + expect(g.isLeaf('b')).to.be.false; + }); + it('returns true for an unconnected node in undirected graph', function () { + g = new Graph({ directed: false }); + g.setNode('a'); + expect(g.isLeaf('a')).to.be.true; + }); + it('returns true for unconnected node in directed graph', function () { + g.setNode('a'); + expect(g.isLeaf('a')).to.be.true; + }); + it('returns false for predecessor node in directed graph', function () { + g.setNode('a'); + g.setNode('b'); + g.setEdge('a', 'b'); + expect(g.isLeaf('a')).to.be.false; + }); + it('returns true for successor node in directed graph', function () { + g.setNode('a'); + g.setNode('b'); + g.setEdge('a', 'b'); + expect(g.isLeaf('b')).to.be.true; + }); + }); + + describe('edges', function () { + it('is empty if there are no edges in the graph', function () { + expect(g.edges()).to.eql([]); + }); + + it('returns the keys for edges in the graph', function () { + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + expect(g.edges().sort(sortEdges)).to.eql([ + { v: 'a', w: 'b' }, + { v: 'b', w: 'c' }, + ]); + }); + }); + + describe('setPath', function () { + it('creates a path of mutiple edges', function () { + g.setPath(['a', 'b', 'c']); + expect(g.hasEdge('a', 'b')).to.be.true; + expect(g.hasEdge('b', 'c')).to.be.true; + }); + + it('can set a value for all of the edges', function () { + g.setPath(['a', 'b', 'c'], 'foo'); + expect(g.edge('a', 'b')).to.equal('foo'); + expect(g.edge('b', 'c')).to.equal('foo'); + }); + + it('is chainable', function () { + expect(g.setPath(['a', 'b', 'c'])).to.equal(g); + }); + }); + + describe('setEdge', function () { + it("creates the edge if it isn't part of the graph", function () { + g.setNode('a'); + g.setNode('b'); + g.setEdge('a', 'b'); + expect(g.edge('a', 'b')).to.be.undefined; + expect(g.hasEdge('a', 'b')).to.be.true; + expect(g.hasEdge({ v: 'a', w: 'b' })).to.be.true; + expect(g.edgeCount()).to.equal(1); + }); + + it('creates the nodes for the edge if they are not part of the graph', function () { + g.setEdge('a', 'b'); + expect(g.hasNode('a')).to.be.true; + expect(g.hasNode('b')).to.be.true; + expect(g.nodeCount()).to.equal(2); + }); + + it("creates a multi-edge if if it isn't part of the graph", function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b', undefined, 'name'); + expect(g.hasEdge('a', 'b')).to.be.false; + expect(g.hasEdge('a', 'b', 'name')).to.be.true; + }); + + it('throws if a multi-edge is used with a non-multigraph', function () { + expect(function () { + g.setEdge('a', 'b', undefined, 'name'); + }).to.throw(); + }); + + it('changes the value for an edge if it is already in the graph', function () { + g.setEdge('a', 'b', 'foo'); + g.setEdge('a', 'b', 'bar'); + expect(g.edge('a', 'b')).to.equal('bar'); + }); + + it('deletes the value for the edge if the value arg is undefined', function () { + g.setEdge('a', 'b', 'foo'); + g.setEdge('a', 'b', undefined); + expect(g.edge('a', 'b')).to.be.undefined; + expect(g.hasEdge('a', 'b')).to.be.true; + }); + + it('changes the value for a multi-edge if it is already in the graph', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b', 'value', 'name'); + g.setEdge('a', 'b', undefined, 'name'); + expect(g.edge('a', 'b', 'name')).to.be.undefined; + expect(g.hasEdge('a', 'b', 'name')).to.be.true; + }); + + it('can take an edge object as the first parameter', function () { + g.setEdge({ v: 'a', w: 'b' }, 'value'); + expect(g.edge('a', 'b')).to.equal('value'); + }); + + it('can take an multi-edge object as the first parameter', function () { + var g = new Graph({ multigraph: true }); + g.setEdge({ v: 'a', w: 'b', name: 'name' }, 'value'); + expect(g.edge('a', 'b', 'name')).to.equal('value'); + }); + + it('uses the stringified form of the id #1', function () { + g.setEdge(1, 2, 'foo'); + expect(g.edges()).eqls([{ v: '1', w: '2' }]); + expect(g.edge('1', '2')).to.equal('foo'); + expect(g.edge(1, 2)).to.equal('foo'); + }); + + it('uses the stringified form of the id #2', function () { + g = new Graph({ multigraph: true }); + g.setEdge(1, 2, 'foo', undefined); + expect(g.edges()).eqls([{ v: '1', w: '2' }]); + expect(g.edge('1', '2')).to.equal('foo'); + expect(g.edge(1, 2)).to.equal('foo'); + }); + + it('uses the stringified form of the id with a name', function () { + g = new Graph({ multigraph: true }); + g.setEdge(1, 2, 'foo', 3); + expect(g.edge('1', '2', '3')).to.equal('foo'); + expect(g.edge(1, 2, 3)).to.equal('foo'); + expect(g.edges()).eqls([{ v: '1', w: '2', name: '3' }]); + }); + + it('treats edges in opposite directions as distinct in a digraph', function () { + g.setEdge('a', 'b'); + expect(g.hasEdge('a', 'b')).to.be.true; + expect(g.hasEdge('b', 'a')).to.be.false; + }); + + it('handles undirected graph edges', function () { + var g = new Graph({ directed: false }); + g.setEdge('a', 'b', 'foo'); + expect(g.edge('a', 'b')).to.equal('foo'); + expect(g.edge('b', 'a')).to.equal('foo'); + }); + + it('handles undirected edges where id has different order than Stringified id', function () { + var g = new Graph({ directed: false }); + g.setEdge(9, 10, 'foo'); + expect(g.hasEdge('9', '10')).to.be.true; + expect(g.hasEdge(9, 10)).to.be.true; + expect(g.hasEdge('10', '9')).to.be.true; + expect(g.hasEdge(10, 9)).to.be.true; + expect(g.edge('9', '10')).eqls('foo'); + expect(g.edge(9, 10)).eqls('foo'); + }); + + it('is chainable', function () { + expect(g.setEdge('a', 'b')).to.equal(g); + }); + }); + + describe('setDefaultEdgeLabel', function () { + it('sets a default label for new edges', function () { + g.setDefaultEdgeLabel('foo'); + g.setEdge('a', 'b'); + expect(g.edge('a', 'b')).to.equal('foo'); + }); + + it('does not change existing edges', function () { + g.setEdge('a', 'b'); + g.setDefaultEdgeLabel('foo'); + expect(g.edge('a', 'b')).to.be.undefined; + }); + + it('is not used if an explicit value is set', function () { + g.setDefaultEdgeLabel('foo'); + g.setEdge('a', 'b', 'bar'); + expect(g.edge('a', 'b')).to.equal('bar'); + }); + + it('can take a function', function () { + g.setDefaultEdgeLabel(function () { + return 'foo'; + }); + g.setEdge('a', 'b'); + expect(g.edge('a', 'b')).to.equal('foo'); + }); + + it("can take a function that takes the edge's endpoints and name", function () { + var g = new Graph({ multigraph: true }); + g.setDefaultEdgeLabel(function (v, w, name) { + return v + '-' + w + '-' + name + '-foo'; + }); + g.setEdge({ v: 'a', w: 'b', name: 'name' }); + expect(g.edge('a', 'b', 'name')).to.equal('a-b-name-foo'); + }); + + it('does not set a default value for a multi-edge that already exists', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b', 'old', 'name'); + g.setDefaultEdgeLabel(function () { + return 'should not set this'; + }); + g.setEdge({ v: 'a', w: 'b', name: 'name' }); + expect(g.edge('a', 'b', 'name')).to.equal('old'); + }); + + it('is chainable', function () { + expect(g.setDefaultEdgeLabel('foo')).to.equal(g); + }); + }); + + describe('edge', function () { + it("returns undefined if the edge isn't part of the graph", function () { + expect(g.edge('a', 'b')).to.be.undefined; + expect(g.edge({ v: 'a', w: 'b' })).to.be.undefined; + expect(g.edge('a', 'b', 'foo')).to.be.undefined; + }); + + it('returns the value of the edge if it is part of the graph', function () { + g.setEdge('a', 'b', { foo: 'bar' }); + expect(g.edge('a', 'b')).to.eql({ foo: 'bar' }); + expect(g.edge({ v: 'a', w: 'b' })).to.eql({ foo: 'bar' }); + expect(g.edge('b', 'a')).to.be.undefined; + }); + + it('returns the value of a multi-edge if it is part of the graph', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b', { bar: 'baz' }, 'foo'); + expect(g.edge('a', 'b', 'foo')).to.eql({ bar: 'baz' }); + expect(g.edge('a', 'b')).to.be.undefined; + }); + + it('returns an edge in either direction in an undirected graph', function () { + var g = new Graph({ directed: false }); + g.setEdge('a', 'b', { foo: 'bar' }); + expect(g.edge('a', 'b')).to.eql({ foo: 'bar' }); + expect(g.edge('b', 'a')).to.eql({ foo: 'bar' }); + }); + }); + + describe('removeEdge', function () { + it('has no effect if the edge is not in the graph', function () { + g.removeEdge('a', 'b'); + expect(g.hasEdge('a', 'b')).to.be.false; + expect(g.edgeCount()).to.equal(0); + }); + + it('can remove an edge by edgeObj', function () { + var g = new Graph({ multigraph: true }); + g.setEdge({ v: 'a', w: 'b', name: 'foo' }); + g.removeEdge({ v: 'a', w: 'b', name: 'foo' }); + expect(g.hasEdge('a', 'b', 'foo')).to.be.false; + expect(g.edgeCount()).to.equal(0); + }); + + it('can remove an edge by separate ids', function () { + var g = new Graph({ multigraph: true }); + g.setEdge({ v: 'a', w: 'b', name: 'foo' }); + g.removeEdge('a', 'b', 'foo'); + expect(g.hasEdge('a', 'b', 'foo')).to.be.false; + expect(g.edgeCount()).to.equal(0); + }); + + it('correctly removes neighbors', function () { + g.setEdge('a', 'b'); + g.removeEdge('a', 'b'); + expect(g.successors('a')).to.eql([]); + expect(g.neighbors('a')).to.eql([]); + expect(g.predecessors('b')).to.eql([]); + expect(g.neighbors('b')).to.eql([]); + }); + + it('correctly decrements neighbor counts', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b'); + g.setEdge({ v: 'a', w: 'b', name: 'foo' }); + g.removeEdge('a', 'b'); + expect(g.hasEdge('a', 'b', 'foo')); + expect(g.successors('a')).to.eql(['b']); + expect(g.neighbors('a')).to.eql(['b']); + expect(g.predecessors('b')).to.eql(['a']); + expect(g.neighbors('b')).to.eql(['a']); + }); + + it('works with undirected graphs', function () { + var g = new Graph({ directed: false }); + g.setEdge('h', 'g'); + g.removeEdge('g', 'h'); + expect(g.neighbors('g')).to.eql([]); + expect(g.neighbors('h')).to.eql([]); + }); + + it('is chainable', function () { + g.setEdge('a', 'b'); + expect(g.removeEdge('a', 'b')).to.equal(g); + }); + }); + + describe('inEdges', function () { + it('returns undefined for a node that is not in the graph', function () { + expect(g.inEdges('a')).to.be.undefined; + }); + + it('returns the edges that point at the specified node', function () { + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + expect(g.inEdges('a')).to.eql([]); + expect(g.inEdges('b')).to.eql([{ v: 'a', w: 'b' }]); + expect(g.inEdges('c')).to.eql([{ v: 'b', w: 'c' }]); + }); + + it('works for multigraphs', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b'); + g.setEdge('a', 'b', undefined, 'bar'); + g.setEdge('a', 'b', undefined, 'foo'); + expect(g.inEdges('a')).to.eql([]); + expect(g.inEdges('b').sort(sortEdges)).to.eql([ + { v: 'a', w: 'b', name: 'bar' }, + { v: 'a', w: 'b', name: 'foo' }, + { v: 'a', w: 'b' }, + ]); + }); + + it('can return only edges from a specified node', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b'); + g.setEdge('a', 'b', undefined, 'foo'); + g.setEdge('a', 'c'); + g.setEdge('b', 'c'); + g.setEdge('z', 'a'); + g.setEdge('z', 'b'); + expect(g.inEdges('a', 'b')).to.eql([]); + expect(g.inEdges('b', 'a').sort(sortEdges)).to.eql([ + { v: 'a', w: 'b', name: 'foo' }, + { v: 'a', w: 'b' }, + ]); + }); + }); + + describe('outEdges', function () { + it('returns undefined for a node that is not in the graph', function () { + expect(g.outEdges('a')).to.be.undefined; + }); + + it('returns all edges that this node points at', function () { + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + expect(g.outEdges('a')).to.eql([{ v: 'a', w: 'b' }]); + expect(g.outEdges('b')).to.eql([{ v: 'b', w: 'c' }]); + expect(g.outEdges('c')).to.eql([]); + }); + + it('works for multigraphs', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b'); + g.setEdge('a', 'b', undefined, 'bar'); + g.setEdge('a', 'b', undefined, 'foo'); + expect(g.outEdges('a').sort(sortEdges)).to.eql([ + { v: 'a', w: 'b', name: 'bar' }, + { v: 'a', w: 'b', name: 'foo' }, + { v: 'a', w: 'b' }, + ]); + expect(g.outEdges('b')).to.eql([]); + }); + + it('can return only edges to a specified node', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b'); + g.setEdge('a', 'b', undefined, 'foo'); + g.setEdge('a', 'c'); + g.setEdge('b', 'c'); + g.setEdge('z', 'a'); + g.setEdge('z', 'b'); + expect(g.outEdges('a', 'b').sort(sortEdges)).to.eql([ + { v: 'a', w: 'b', name: 'foo' }, + { v: 'a', w: 'b' }, + ]); + expect(g.outEdges('b', 'a')).to.eql([]); + }); + }); + + describe('nodeEdges', function () { + it('returns undefined for a node that is not in the graph', function () { + expect(g.nodeEdges('a')).to.be.undefined; + }); + + it('returns all edges that this node points at', function () { + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + expect(g.nodeEdges('a')).to.eql([{ v: 'a', w: 'b' }]); + expect(g.nodeEdges('b').sort(sortEdges)).to.eql([ + { v: 'a', w: 'b' }, + { v: 'b', w: 'c' }, + ]); + expect(g.nodeEdges('c')).to.eql([{ v: 'b', w: 'c' }]); + }); + + it('works for multigraphs', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b'); + g.setEdge({ v: 'a', w: 'b', name: 'bar' }); + g.setEdge({ v: 'a', w: 'b', name: 'foo' }); + expect(g.nodeEdges('a').sort(sortEdges)).to.eql([ + { v: 'a', w: 'b', name: 'bar' }, + { v: 'a', w: 'b', name: 'foo' }, + { v: 'a', w: 'b' }, + ]); + expect(g.nodeEdges('b').sort(sortEdges)).to.eql([ + { v: 'a', w: 'b', name: 'bar' }, + { v: 'a', w: 'b', name: 'foo' }, + { v: 'a', w: 'b' }, + ]); + }); + + it('can return only edges between specific nodes', function () { + var g = new Graph({ multigraph: true }); + g.setEdge('a', 'b'); + g.setEdge({ v: 'a', w: 'b', name: 'foo' }); + g.setEdge('a', 'c'); + g.setEdge('b', 'c'); + g.setEdge('z', 'a'); + g.setEdge('z', 'b'); + expect(g.nodeEdges('a', 'b').sort(sortEdges)).to.eql([ + { v: 'a', w: 'b', name: 'foo' }, + { v: 'a', w: 'b' }, + ]); + expect(g.nodeEdges('b', 'a').sort(sortEdges)).to.eql([ + { v: 'a', w: 'b', name: 'foo' }, + { v: 'a', w: 'b' }, + ]); + }); + }); +}); + +function sortEdges(a, b) { + if (a.name) { + return a.name.localeCompare(b.name); + } + + const order = a.v.localeCompare(b.v); + if (order != 0) { + return order; + } + + return a.w.localeCompare(b.w); +} diff --git a/src/graphlib/json.test.js b/src/graphlib/json.test.js new file mode 100644 index 0000000..c222189 --- /dev/null +++ b/src/graphlib/json.test.js @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from './graph.js'; +import { read, write } from './json.js'; + +describe('json', function () { + it('preserves the graph options', function () { + expect(rw(new Graph({ directed: true })).isDirected()).to.be.true; + expect(rw(new Graph({ directed: false })).isDirected()).to.be.false; + expect(rw(new Graph({ multigraph: true })).isMultigraph()).to.be.true; + expect(rw(new Graph({ multigraph: false })).isMultigraph()).to.be.false; + expect(rw(new Graph({ compound: true })).isCompound()).to.be.true; + expect(rw(new Graph({ compound: false })).isCompound()).to.be.false; + }); + + it('preserves the graph value, if any', function () { + expect(rw(new Graph().setGraph(1)).graph()).equals(1); + expect(rw(new Graph().setGraph({ foo: 'bar' })).graph()).eqls({ foo: 'bar' }); + expect(rw(new Graph()).graph()).to.be.undefined; + }); + + it('preserves nodes', function () { + expect(rw(new Graph().setNode('a')).hasNode('a')).to.be.true; + expect(rw(new Graph().setNode('a')).node('a')).to.be.undefined; + expect(rw(new Graph().setNode('a', 1)).node('a')).equals(1); + expect(rw(new Graph().setNode('a', { foo: 'bar' })).node('a')).eqls({ foo: 'bar' }); + }); + + it('preserves simple edges', function () { + expect(rw(new Graph().setEdge('a', 'b')).hasEdge('a', 'b')).to.be.true; + expect(rw(new Graph().setEdge('a', 'b')).edge('a', 'b')).to.be.undefined; + expect(rw(new Graph().setEdge('a', 'b', 1)).edge('a', 'b')).equals(1); + expect(rw(new Graph().setEdge('a', 'b', { foo: 'bar' })).edge('a', 'b')).eqls({ foo: 'bar' }); + }); + + it('preserves multi-edges', function () { + var g = new Graph({ multigraph: true }); + + g.setEdge({ v: 'a', w: 'b', name: 'foo' }); + expect(rw(g).hasEdge('a', 'b', 'foo')).to.be.true; + + g.setEdge({ v: 'a', w: 'b', name: 'foo' }); + expect(rw(g).edge('a', 'b', 'foo')).to.be.undefined; + + g.setEdge({ v: 'a', w: 'b', name: 'foo' }, 1); + expect(rw(g).edge('a', 'b', 'foo')).equals(1); + + g.setEdge({ v: 'a', w: 'b', name: 'foo' }, { foo: 'bar' }); + expect(rw(g).edge('a', 'b', 'foo')).eqls({ foo: 'bar' }); + }); + + it('preserves parent / child relationships', function () { + expect(rw(new Graph({ compound: true }).setNode('a')).parent('a')).to.be.undefined; + expect(rw(new Graph({ compound: true }).setParent('a', 'parent')).parent('a')).to.equal( + 'parent', + ); + }); +}); + +function rw(g) { + return read(write(g)); +} diff --git a/src/lodash.js b/src/lodash.js new file mode 100644 index 0000000..562dba1 --- /dev/null +++ b/src/lodash.js @@ -0,0 +1,11 @@ +// https://github.com/lodash/lodash/blob/main/src/has.ts +const hasOwnProperty = Object.prototype.hasOwnProperty; + +/* + * @param {Object} object The object to query. + * @param {string} key The key to check. + * @returns {boolean} Returns `true` if `key` exists, else `false`. + */ +export function _has(object, key) { + return object != null && hasOwnProperty.call(object, key); +} diff --git a/test/graphlib/alg/all-shortest-paths.js b/test/graphlib/alg/all-shortest-paths.js new file mode 100644 index 0000000..9f80b89 --- /dev/null +++ b/test/graphlib/alg/all-shortest-paths.js @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { Graph } from '../../../src/graphlib/graph.js'; + +export function allShortestPathsTests(sp) { + describe('allShortestPaths', function () { + it('returns 0 for the node itself', function () { + var g = new Graph(); + g.setNode('a'); + expect(sp(g)).to.eql({ a: { a: { distance: 0 } } }); + }); + + it('returns the distance and path from all nodes to other nodes', function () { + var g = new Graph(); + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + expect(sp(g)).to.eql({ + a: { + a: { distance: 0 }, + b: { distance: 1, predecessor: 'a' }, + c: { distance: 2, predecessor: 'b' }, + }, + b: { + a: { distance: Number.POSITIVE_INFINITY }, + b: { distance: 0 }, + c: { distance: 1, predecessor: 'b' }, + }, + c: { + a: { distance: Number.POSITIVE_INFINITY }, + b: { distance: Number.POSITIVE_INFINITY }, + c: { distance: 0 }, + }, + }); + }); + + it('uses an optionally supplied weight function', function () { + var g = new Graph(); + g.setEdge('a', 'b', 2); + g.setEdge('b', 'c', 3); + + expect(sp(g, weightFn(g))).to.eql({ + a: { + a: { distance: 0 }, + b: { distance: 2, predecessor: 'a' }, + c: { distance: 5, predecessor: 'b' }, + }, + b: { + a: { distance: Number.POSITIVE_INFINITY }, + b: { distance: 0 }, + c: { distance: 3, predecessor: 'b' }, + }, + c: { + a: { distance: Number.POSITIVE_INFINITY }, + b: { distance: Number.POSITIVE_INFINITY }, + c: { distance: 0 }, + }, + }); + }); + + it('uses an optionally supplied incident function', function () { + var g = new Graph(); + g.setEdge('a', 'b'); + g.setEdge('b', 'c'); + + expect( + sp(g, undefined, function (v) { + return g.inEdges(v); + }), + ).to.eql({ + a: { + a: { distance: 0 }, + b: { distance: Number.POSITIVE_INFINITY }, + c: { distance: Number.POSITIVE_INFINITY }, + }, + b: { + a: { distance: 1, predecessor: 'b' }, + b: { distance: 0 }, + c: { distance: Number.POSITIVE_INFINITY }, + }, + c: { + a: { distance: 2, predecessor: 'b' }, + b: { distance: 1, predecessor: 'c' }, + c: { distance: 0 }, + }, + }); + }); + + it('works with undirected graphs', function () { + var g = new Graph({ directed: false }); + g.setEdge('a', 'b', 1); + g.setEdge('b', 'c', 2); + g.setEdge('c', 'a', 4); + g.setEdge('b', 'd', 6); + + expect(sp(g, weightFn(g), g.nodeEdges.bind(g))).to.eql({ + a: { + a: { distance: 0 }, + b: { distance: 1, predecessor: 'a' }, + c: { distance: 3, predecessor: 'b' }, + d: { distance: 7, predecessor: 'b' }, + }, + b: { + a: { distance: 1, predecessor: 'b' }, + b: { distance: 0 }, + c: { distance: 2, predecessor: 'b' }, + d: { distance: 6, predecessor: 'b' }, + }, + c: { + a: { distance: 3, predecessor: 'b' }, + b: { distance: 2, predecessor: 'c' }, + c: { distance: 0 }, + d: { distance: 8, predecessor: 'b' }, + }, + d: { + a: { distance: 7, predecessor: 'b' }, + b: { distance: 6, predecessor: 'd' }, + c: { distance: 8, predecessor: 'b' }, + d: { distance: 0 }, + }, + }); + }); + }); +} + +function weightFn(g) { + return function (e) { + return g.edge(e); + }; +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..cdc26a5 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,6 @@ +export default { + test: { + // we can't do `import {it} from "vitest"` due to https://github.com/vitejs/vite/issues/11552 + globals: true, + }, +};