diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2af7724..051dc54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,12 +2,12 @@ name: Release to NPM on: release: - types: [published] + types: [ published ] jobs: publish: runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -22,10 +22,13 @@ jobs: - name: Install dependencies run: npm ci + - name: Run lint + run: npm lint + - name: Run tests run: npm test - name: Publish to NPM run: npm publish env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 241d7f1..6a5fe13 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Unit Tests on: push: - branches: ["master"] + branches: [ "master" ] pull_request: - branches: ["master"] + branches: [ "master" ] jobs: tests: @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [16.x, 18.x] + node-version: [ 16.x, 18.x ] steps: - uses: actions/checkout@v3 @@ -24,9 +24,14 @@ jobs: node-version: ${{ matrix.node-version }} cache: "npm" - - run: npm ci + - name: Install dependencies + run: npm ci - - run: npm test + - name: Run lint + run: npm lint + + - name: Run tests + run: npm test - uses: codecov/codecov-action@v3 with: diff --git a/.gitignore b/.gitignore index 95b5663..95cde4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules *.log coverage -.nyc_output \ No newline at end of file +.nyc_output +.idea diff --git a/libs/Graph.js b/libs/Graph.js index fb19770..99e57ad 100644 --- a/libs/Graph.js +++ b/libs/Graph.js @@ -134,9 +134,12 @@ class Graph { * @param {string} goal - Node we want to reach * @param {object} [options] - Options * - * @param {boolean} [options.trim] - Exclude the origin and destination nodes from the result - * @param {boolean} [options.reverse] - Return the path in reversed order - * @param {boolean} [options.cost] - Also return the cost of the path when set to true + * @param {boolean} [options.trim] - Exclude the origin and destination nodes from the result + * @param {boolean} [options.reverse] - Return the path in reversed order + * @param {boolean} [options.cost] - Also return the cost of the path when set to true + * @param {number} [options.maxCost] - Only consider paths with total cost less than or equal to this value + * @param {number} [options.maxNodes] - Maximum number of nodes allowed in the resulting path (including start and goal) + * @param {function} [options.canVisit] - A predicate invoked for each potential edge expansion. Receives an object { from, to, cost, accumulatedCost, depth } and must return true to allow the move * * @return {array|object} Computed path between the nodes. * @@ -179,6 +182,7 @@ class Graph { const explored = new Set(); const frontier = new Queue(); const previous = new Map(); + const depth = new Map(); let path = []; let totalCost = 0; @@ -192,14 +196,32 @@ class Graph { throw new Error(`Ending node (${goal}) cannot be avoided`); } + const hasMaxCost = + typeof options.maxCost === "number" && !Number.isNaN(options.maxCost); + const maxCost = hasMaxCost ? Number(options.maxCost) : undefined; + const hasMaxNodes = + typeof options.maxNodes === "number" && !Number.isNaN(options.maxNodes); + const maxNodes = hasMaxNodes + ? Math.max(1, Math.floor(options.maxNodes)) + : undefined; + const canVisit = typeof options.canVisit === "function" ? options.canVisit : null; + // Add the starting point to the frontier, it will be the first node visited frontier.set(start, 0); + depth.set(start, 1); // Run until we have visited every node in the frontier while (!frontier.isEmpty()) { // Get the node in the frontier with the lowest cost (`priority`) const node = frontier.next(); + // If the cheapest node already exceeds maxCost, no valid path can be found + if (hasMaxCost && node.priority > maxCost) { + // ensure path remains empty so we return null later + path = []; + break; + } + // When the node with the lowest cost in the frontier in our goal node, // we can compute the path and exit the loop if (node.key === goal) { @@ -224,21 +246,44 @@ class Graph { // If we already explored the node, or the node is to be avoided, skip it if (explored.has(nNode) || avoid.includes(nNode)) return null; + const newCost = node.priority + nCost; + const currentDepth = depth.get(node.key) || 1; + const newDepth = currentDepth + 1; + + // Enforce maximum number of nodes in the resulting path (including start and goal) + if (hasMaxNodes && newDepth > maxNodes) return null; + + // Enforce maximum cost constraint + if (hasMaxCost && newCost > maxCost) return null; + + // If provided, consult the canVisit to decide whether to expand this edge + if (canVisit) { + const allowed = canVisit({ + from: node.key, + to: nNode, + cost: nCost, + accumulatedCost: newCost, + depth: newDepth, + }); + if (!allowed) return null; + } + // If the neighboring node is not yet in the frontier, we add it with // the correct cost if (!frontier.has(nNode)) { previous.set(nNode, node.key); - return frontier.set(nNode, node.priority + nCost); + depth.set(nNode, newDepth); + return frontier.set(nNode, newCost); } const frontierPriority = frontier.get(nNode).priority; - const nodeCost = node.priority + nCost; // Otherwise we only update the cost of this node in the frontier when // it's below what's currently set - if (nodeCost < frontierPriority) { + if (newCost < frontierPriority) { previous.set(nNode, node.key); - return frontier.set(nNode, nodeCost); + depth.set(nNode, newDepth); + return frontier.set(nNode, newCost); } return null; diff --git a/libs/index.d.ts b/libs/index.d.ts new file mode 100644 index 0000000..d1670ff --- /dev/null +++ b/libs/index.d.ts @@ -0,0 +1,160 @@ +declare class Graph { + /** + * Creates a new Graph, optionally initializing it a nodes graph representation. + * + * A graph representation is an object that has as keys the name of the point and as values + * the points reachable from that node, with the cost to get there: + * + * { + * node (Number|String): { + * neighbor (Number|String): cost (Number), + * ..., + * }, + * } + * + * In alternative to an object, you can pass a `Map` of `Map`. This will + * allow you to specify numbers as keys. + * + * @param [graph] - Initial graph definition + * @example + * + * const route = new Graph(); + * + * // Pre-populated graph + * const route = new Graph({ + * A: { B: 1 }, + * B: { A: 1, C: 2, D: 4 }, + * }); + * + * // Passing a Map + * const g = new Map() + * + * const a = new Map() + * a.set('B', 1) + * + * const b = new Map() + * b.set('A', 1) + * b.set('C', 2) + * b.set('D', 4) + * + * g.set('A', a) + * g.set('B', b) + * + * const route = new Graph(g) + */ + constructor(nodes?: { [key: string]: { [key: string]: number } } | Map>); + + /** + * Adds a node to the graph + * + * @param name - Name of the node + * @param neighbors - Neighboring nodes and cost to reach them + * @example + * + * const route = new Graph(); + * + * route.addNode('A', { B: 1 }); + * + * // It's possible to chain the calls + * route + * .addNode('B', { A: 1 }) + * .addNode('C', { A: 3 }); + * + * // The neighbors can be expressed in a Map + * const d = new Map() + * d.set('A', 2) + * d.set('B', 8) + * + * route.addNode('D', d) + */ + addNode(name: string, neighbors: any): Graph; + + /** + * Removes a node and all of its references from the graph + * + * @param key - Key of the node to remove from the graph + * @example + * + * const route = new Graph({ + * A: { B: 1, C: 5 }, + * B: { A: 3 }, + * C: { B: 2, A: 2 }, + * }); + * + * route.removeNode('C'); + * // The graph now is: + * // { A: { B: 1 }, B: { A: 3 } } + */ + removeNode(name: string): Graph; + + /** + * Compute the shortest path between the specified nodes + * + * @param start - Starting node + * @param goal - Node we want to reach + * @param [options] - Options + * + * @param [options.trim] - Exclude the origin and destination nodes from the result + * @param [options.reverse] - Return the path in reversed order + * @param [options.cost] - Also return the cost of the path when set to true + * @param [options.maxCost] - Only consider paths with total cost ≤ this value + * @param [options.maxNodes] - Maximum number of nodes allowed in the resulting path (including start and goal) + * @param [options.canVisit] - Predicate called for each potential expansion; returns true to allow the move + * + * @return Computed path between the nodes. + * + * When `option.cost` is set to true, the returned value will be an object with shape: + * - `path` *(Array)*: Computed path between the nodes + * - `cost` *(Number)*: Cost of the path + * + * @example + * + * const route = new Graph() + * + * route.addNode('A', { B: 1 }) + * route.addNode('B', { A: 1, C: 2, D: 4 }) + * route.addNode('C', { B: 2, D: 1 }) + * route.addNode('D', { C: 1, B: 4 }) + * + * route.path('A', 'D') // => ['A', 'B', 'C', 'D'] + * + * // trimmed + * route.path('A', 'D', { trim: true }) // => [B', 'C'] + * + * // reversed + * route.path('A', 'D', { reverse: true }) // => ['D', 'C', 'B', 'A'] + * + * // include the cost + * route.path('A', 'D', { cost: true }) + * // => { + * // path: [ 'A', 'B', 'C', 'D' ], + * // cost: 4 + * // } + */ + path(start: any, goal: any, options?: PathOption): string[] | PathResult; +} + +interface PathOption { + trim?: boolean | undefined; + reverse?: boolean | undefined; + cost?: boolean | undefined; + avoid?: any[] | undefined; + maxCost?: number | undefined; + maxNodes?: number | undefined; + canVisit?: ((arg: CanVisitArg) => boolean) | undefined; +} + +interface CanVisitArg { + from: string; + to: string; + cost: number; + accumulatedCost: number; + depth: number; +} + +interface PathResult { + path: string[] | null; + cost: number; +} + +export = Graph; diff --git a/package-lock.json b/package-lock.json index addece8..a1e9002 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "node-dijkstra", - "version": "2.5.0", + "version": "2.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "node-dijkstra", - "version": "2.5.0", + "version": "2.5.1", "license": "MIT", "devDependencies": { "@babel/core": "^7.20.5", diff --git a/package.json b/package.json index 245cad3..e272ab8 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,13 @@ "description": "A NodeJS implementation of Dijkstra's algorithm", "author": "Alberto Restifo ", "main": "libs/Graph.js", + "types": "libs/index.d.ts", "repository": { "type": "git", "url": "https://github.com/albertorestifo/node-dijkstra" }, "scripts": { - "test": "eslint . && nyc --reporter=html mocha -t 5000", + "test": "nyc --reporter=html mocha -t 5000", "lint": "eslint .", "lint:fix": "eslint . --fix", "compile": "gulp build" diff --git a/test/Graph.test.js b/test/Graph.test.js index b547884..43ba0fb 100644 --- a/test/Graph.test.js +++ b/test/Graph.test.js @@ -327,4 +327,163 @@ describe("Graph", () => { route.graph.get("a").has("c").must.be.false(); }); }); + + describe("#path() with maxCost", () => { + it("returns null when the cheapest path exceeds maxCost (no cost)", () => { + const route = new Graph(); + route.addNode("A", { B: 1 }); + route.addNode("B", { A: 1, C: 2, D: 4 }); + route.addNode("C", { B: 2, D: 1 }); + route.addNode("D", { C: 1, B: 4 }); + + const path = route.path("A", "D", { maxCost: 3 }); + demand(path).be.null(); + }); + + it("returns {path:null,cost:0} when exceeding maxCost and cost:true", () => { + const route = new Graph(); + route.addNode("A", { B: 1 }); + route.addNode("B", { A: 1, C: 2, D: 4 }); + route.addNode("C", { B: 2, D: 1 }); + route.addNode("D", { C: 1, B: 4 }); + + const res = route.path("A", "D", { maxCost: 3, cost: true }); + demand(res.path).be.null(); + res.cost.must.equal(0); + }); + + it("returns the normal path when within maxCost", () => { + const route = new Graph(); + route.addNode("A", { B: 1 }); + route.addNode("B", { A: 1, C: 2, D: 4 }); + route.addNode("C", { B: 2, D: 1 }); + route.addNode("D", { C: 1, B: 4 }); + + const res = route.path("A", "D", { maxCost: 4, cost: true }); + res.path.must.eql(["A", "B", "C", "D"]); + res.cost.must.equal(4); + }); + }); + + describe("#path() with maxNodes", () => { + it("selects a longer-cost alternative if it satisfies the node limit", () => { + // Graph where shortest path A-B-C-D (4 nodes, cost 4), but A-B-D (3 nodes, cost 5) exists + const route = new Graph(); + route.addNode("A", { B: 1 }); + route.addNode("B", { A: 1, C: 2, D: 4 }); + route.addNode("C", { B: 2, D: 1 }); + route.addNode("D", { C: 1, B: 4 }); + + const res = route.path("A", "D", { maxNodes: 3, cost: true }); + res.path.must.eql(["A", "B", "D"]); + res.cost.must.equal(5); + }); + + it("returns null if no path satisfies the node limit", () => { + // With maxNodes = 2, a direct edge A-D would be required but it does not exist + const route = new Graph(); + route.addNode("A", { B: 1 }); + route.addNode("B", { A: 1, C: 2, D: 4 }); + route.addNode("C", { B: 2, D: 1 }); + route.addNode("D", { C: 1, B: 4 }); + + const res = route.path("A", "D", { maxNodes: 2, cost: true }); + demand(res.path).be.null(); + res.cost.must.equal(0); + }); + }); + + describe("#path() with canVisit", () => { + it("can forbid specific edges and still find an alternative path", () => { + const route = new Graph(); + route.addNode("A", { B: 1 }); + route.addNode("B", { A: 1, C: 2, D: 4 }); + route.addNode("C", { B: 2, D: 1 }); + route.addNode("D", { C: 1, B: 4 }); + + const res = route.path("A", "D", { + cost: true, + canVisit: ({ to }) => to !== "C", // prevent going to C + }); + + res.path.must.eql(["A", "B", "D"]); + res.cost.must.equal(5); + }); + + it("can block all expansions, resulting in no path", () => { + const route = new Graph(); + route.addNode("A", { B: 1 }); + route.addNode("B", { A: 1, C: 2, D: 4 }); + route.addNode("C", { B: 2, D: 1 }); + route.addNode("D", { C: 1, B: 4 }); + + const res = route.path("A", "D", { + cost: true, + canVisit: () => false, + }); + + demand(res.path).be.null(); + res.cost.must.equal(0); + }); + + it("receives correct argument shape (from, to, cost, accumulatedCost, depth)", () => { + const route = new Graph(); + route.addNode("A", { B: 1 }); + route.addNode("B", { A: 1 }); + + const spy = sinon.spy(({ from, to, cost, accumulatedCost, depth }) => { + // allow everything + return true; + }); + + route.path("A", "B", { canVisit: spy }); + + sinon.assert.called(spy); + // Check at least one call has the expected shape + const found = spy.args.some((args) => { + const p = args[0] || {}; + return ( + typeof p.from !== "undefined" && + typeof p.to !== "undefined" && + typeof p.cost === "number" && + typeof p.accumulatedCost === "number" && + typeof p.depth === "number" + ); + }); + demand(found).be.true(); + }); + + it("calculates depth and weight correctly across branches", () => { + // Build a small tree so each target node has a unique depth + // A (depth: 1, cost: 0) + // ├─ B (depth: 2, cost: 1) + // │ └─ D (depth: 3, cost: 2) + // └─ C (depth: 2, cost: 2) + // └─ E (depth: 3, cost: 5) + const route = new Graph(); + route.addNode("A", { B: 1, C: 2 }); + route.addNode("B", { D: 1 }); + route.addNode("C", { E: 3 }); + + const spy = sinon.spy(() => true); + + // Use an unreachable goal so the search explores all branches + route.path("A", "Z", { canVisit: spy, cost: true }); + + // Expected depths for each expanded edge + const expected = new Map([ + ["A->B", { depth: 2, accumulatedCost: 1 }], + ["A->C", { depth: 2, accumulatedCost: 2 }], + ["B->D", { depth: 3, accumulatedCost: 2 }], + ["C->E", { depth: 3, accumulatedCost: 5 }], + ]); + + // Check observed depths + for (const args of spy.args) { + const expectedInfo = expected.get(`${args[0].from}->${args[0].to}`); + demand(args[0].depth).to.equal(expectedInfo.depth); + demand(args[0].accumulatedCost).to.equal(expectedInfo.accumulatedCost); + } + }); + }); });