From 83753213d20f185df0c3c0a58bcc3c9d0ba9eb7c Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 4 Nov 2025 21:22:48 +0100 Subject: [PATCH 1/9] feat: add native types --- libs/index.d.ts | 146 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 1 + tsconfig.json | 19 ++++++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 libs/index.d.ts create mode 100644 tsconfig.json diff --git a/libs/index.d.ts b/libs/index.d.ts new file mode 100644 index 0000000..023af41 --- /dev/null +++ b/libs/index.d.ts @@ -0,0 +1,146 @@ +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 + * + * @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; +} + +interface PathResult { + path: string[]; + 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..0c4d902 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "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" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cb6b764 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "node16", + "lib": [ + "es6" + ], + "noImplicitAny": true, + "noImplicitThis": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "types": [], + "noEmit": true, + "forceConsistentCasingInFileNames": true + }, + "files": [ + "index.d.ts", + "node-dijkstra-tests.ts" + ] +} From a9014f54919ab9bad6e1e7f5eca59df748302f37 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 4 Nov 2025 22:47:34 +0100 Subject: [PATCH 2/9] feat: enhance pathfinding options with maxCost, maxNodes, and allowedCallback (closes #46) --- .gitignore | 3 +- libs/Graph.js | 49 ++++++++++++++++-- libs/index.d.ts | 16 +++++- test/Graph.test.js | 126 +++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 19 ------- 5 files changed, 188 insertions(+), 25 deletions(-) delete mode 100644 tsconfig.json 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..3b81972 100644 --- a/libs/Graph.js +++ b/libs/Graph.js @@ -137,6 +137,9 @@ class Graph { * @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.allowedCallback] - 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,28 @@ 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 allowedCallback = typeof options.allowedCallback === 'function' ? options.allowedCallback : 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 +242,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 allowedCallback to decide whether to expand this edge + if (allowedCallback) { + const allowed = allowedCallback({ + 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 index 023af41..01a3982 100644 --- a/libs/index.d.ts +++ b/libs/index.d.ts @@ -97,6 +97,9 @@ declare class Graph { * @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.allowedCallback] - Predicate called for each potential expansion; returns true to allow the move * * @return Computed path between the nodes. * @@ -136,10 +139,21 @@ interface PathOption { reverse?: boolean | undefined; cost?: boolean | undefined; avoid?: any[] | undefined; + maxCost?: number | undefined; + maxNodes?: number | undefined; + allowedCallback?: ((arg: AllowedCallbackArg) => boolean) | undefined; +} + +interface AllowedCallbackArg { + from: string; + to: string; + cost: number; + accumulatedCost: number; + depth: number; } interface PathResult { - path: string[]; + path: string[] | null; cost: number; } diff --git a/test/Graph.test.js b/test/Graph.test.js index b547884..4c77c5f 100644 --- a/test/Graph.test.js +++ b/test/Graph.test.js @@ -327,4 +327,130 @@ 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 allowedCallback", () => { + 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, + allowedCallback: ({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, + allowedCallback: () => 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", {allowedCallback: 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(); + }); + }); }); diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index cb6b764..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "module": "node16", - "lib": [ - "es6" - ], - "noImplicitAny": true, - "noImplicitThis": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "types": [], - "noEmit": true, - "forceConsistentCasingInFileNames": true - }, - "files": [ - "index.d.ts", - "node-dijkstra-tests.ts" - ] -} From 6ed472e7625cfa1959c43d186703ac229a1fd59c Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Thu, 6 Nov 2025 16:34:10 +0100 Subject: [PATCH 3/9] refactor: fix lint errors --- libs/Graph.js | 15 ++++++--- test/Graph.test.js | 76 +++++++++++++++++++++++----------------------- 2 files changed, 49 insertions(+), 42 deletions(-) diff --git a/libs/Graph.js b/libs/Graph.js index 3b81972..ec00c1d 100644 --- a/libs/Graph.js +++ b/libs/Graph.js @@ -196,11 +196,18 @@ class Graph { throw new Error(`Ending node (${goal}) cannot be avoided`); } - const hasMaxCost = typeof options.maxCost === 'number' && !Number.isNaN(options.maxCost); + 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 allowedCallback = typeof options.allowedCallback === 'function' ? options.allowedCallback : null; + const hasMaxNodes = + typeof options.maxNodes === "number" && !Number.isNaN(options.maxNodes); + const maxNodes = hasMaxNodes + ? Math.max(1, Math.floor(options.maxNodes)) + : undefined; + const allowedCallback = + typeof options.allowedCallback === "function" + ? options.allowedCallback + : null; // Add the starting point to the frontier, it will be the first node visited frontier.set(start, 0); diff --git a/test/Graph.test.js b/test/Graph.test.js index 4c77c5f..56f60d3 100644 --- a/test/Graph.test.js +++ b/test/Graph.test.js @@ -331,35 +331,35 @@ describe("Graph", () => { 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}); + 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}); + 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}); + 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}); + 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}); + 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}); + const res = route.path("A", "D", { maxCost: 4, cost: true }); res.path.must.eql(["A", "B", "C", "D"]); res.cost.must.equal(4); }); @@ -369,12 +369,12 @@ describe("Graph", () => { 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}); + 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}); + const res = route.path("A", "D", { maxNodes: 3, cost: true }); res.path.must.eql(["A", "B", "D"]); res.cost.must.equal(5); }); @@ -382,12 +382,12 @@ describe("Graph", () => { 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}); + 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}); + const res = route.path("A", "D", { maxNodes: 2, cost: true }); demand(res.path).be.null(); res.cost.must.equal(0); }); @@ -396,14 +396,14 @@ describe("Graph", () => { describe("#path() with allowedCallback", () => { 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}); + 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, - allowedCallback: ({to}) => to !== "C", // prevent going to C + allowedCallback: ({ to }) => to !== "C", // prevent going to C }); res.path.must.eql(["A", "B", "D"]); @@ -412,10 +412,10 @@ describe("Graph", () => { 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}); + 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, @@ -428,15 +428,15 @@ describe("Graph", () => { it("receives correct argument shape (from, to, cost, accumulatedCost, depth)", () => { const route = new Graph(); - route.addNode("A", {B: 1}); - route.addNode("B", {A: 1}); + route.addNode("A", { B: 1 }); + route.addNode("B", { A: 1 }); - const spy = sinon.spy(({from, to, cost, accumulatedCost, depth}) => { + const spy = sinon.spy(({ from, to, cost, accumulatedCost, depth }) => { // allow everything return true; }); - route.path("A", "B", {allowedCallback: spy}); + route.path("A", "B", { allowedCallback: spy }); sinon.assert.called(spy); // Check at least one call has the expected shape From a3dbc16ddd2eb9e55d3c084ff004f0b02bc7664f Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Thu, 6 Nov 2025 16:37:15 +0100 Subject: [PATCH 4/9] refactor: rename allowed callback to can visit --- libs/Graph.js | 24 ++++++++++++------------ libs/index.d.ts | 16 ++++++++-------- test/Graph.test.js | 8 ++++---- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/libs/Graph.js b/libs/Graph.js index ec00c1d..fdc152b 100644 --- a/libs/Graph.js +++ b/libs/Graph.js @@ -134,12 +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 {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.allowedCallback] - A predicate invoked for each potential edge expansion. Receives an object { from, to, cost, accumulatedCost, depth } and must return true to allow the move + * @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. * @@ -204,9 +204,9 @@ class Graph { const maxNodes = hasMaxNodes ? Math.max(1, Math.floor(options.maxNodes)) : undefined; - const allowedCallback = - typeof options.allowedCallback === "function" - ? options.allowedCallback + const canVisit = + typeof options.canVisit === "function" + ? options.canVisit : null; // Add the starting point to the frontier, it will be the first node visited @@ -259,9 +259,9 @@ class Graph { // Enforce maximum cost constraint if (hasMaxCost && newCost > maxCost) return null; - // If provided, consult the allowedCallback to decide whether to expand this edge - if (allowedCallback) { - const allowed = allowedCallback({ + // If provided, consult the canVisit to decide whether to expand this edge + if (canVisit) { + const allowed = canVisit({ from: node.key, to: nNode, cost: nCost, diff --git a/libs/index.d.ts b/libs/index.d.ts index 01a3982..45d51fc 100644 --- a/libs/index.d.ts +++ b/libs/index.d.ts @@ -94,12 +94,12 @@ declare class Graph { * @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.allowedCallback] - Predicate called for each potential expansion; returns true to allow the move + * @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. * @@ -141,10 +141,10 @@ interface PathOption { avoid?: any[] | undefined; maxCost?: number | undefined; maxNodes?: number | undefined; - allowedCallback?: ((arg: AllowedCallbackArg) => boolean) | undefined; + canVisit?: ((arg: canVisitArg) => boolean) | undefined; } -interface AllowedCallbackArg { +interface canVisitArg { from: string; to: string; cost: number; diff --git a/test/Graph.test.js b/test/Graph.test.js index 56f60d3..a7612fe 100644 --- a/test/Graph.test.js +++ b/test/Graph.test.js @@ -393,7 +393,7 @@ describe("Graph", () => { }); }); - describe("#path() with allowedCallback", () => { + describe("#path() with canVisit", () => { it("can forbid specific edges and still find an alternative path", () => { const route = new Graph(); route.addNode("A", { B: 1 }); @@ -403,7 +403,7 @@ describe("Graph", () => { const res = route.path("A", "D", { cost: true, - allowedCallback: ({ to }) => to !== "C", // prevent going to C + canVisit: ({ to }) => to !== "C", // prevent going to C }); res.path.must.eql(["A", "B", "D"]); @@ -419,7 +419,7 @@ describe("Graph", () => { const res = route.path("A", "D", { cost: true, - allowedCallback: () => false, + canVisit: () => false, }); demand(res.path).be.null(); @@ -436,7 +436,7 @@ describe("Graph", () => { return true; }); - route.path("A", "B", { allowedCallback: spy }); + route.path("A", "B", { canVisit: spy }); sinon.assert.called(spy); // Check at least one call has the expected shape From 308a6699dd2f62910afc9a8d58be6930ebd889bd Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Thu, 6 Nov 2025 16:44:22 +0100 Subject: [PATCH 5/9] build: separate test lint from test --- .github/workflows/release.yml | 9 ++++++--- .github/workflows/test.yml | 15 ++++++++++----- package.json | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) 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/package.json b/package.json index 0c4d902..e272ab8 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "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" From 1690ef8c1d24a680a6d518ff093c9e2b753e72a0 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Thu, 6 Nov 2025 16:44:31 +0100 Subject: [PATCH 6/9] refactor: fix lint errors --- libs/Graph.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/libs/Graph.js b/libs/Graph.js index fdc152b..99e57ad 100644 --- a/libs/Graph.js +++ b/libs/Graph.js @@ -204,10 +204,7 @@ class Graph { const maxNodes = hasMaxNodes ? Math.max(1, Math.floor(options.maxNodes)) : undefined; - const canVisit = - typeof options.canVisit === "function" - ? options.canVisit - : null; + 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); From 94447b37a5bbc54dc8dc7ac90f0816ff75434aba Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Thu, 6 Nov 2025 16:49:25 +0100 Subject: [PATCH 7/9] test: add tests for expected depths --- test/Graph.test.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/Graph.test.js b/test/Graph.test.js index a7612fe..f8c3f98 100644 --- a/test/Graph.test.js +++ b/test/Graph.test.js @@ -452,5 +452,39 @@ describe("Graph", () => { }); demand(found).be.true(); }); + + it("calculates depth correctly across branches", () => { + // Build a small tree so each target node has a unique depth + // A (1) + // ├─ B (2) + // │ └─ D (3) + // └─ C (2) + // └─ E (3) + const route = new Graph(); + route.addNode("A", { B: 1, C: 1 }); + route.addNode("B", { A: 1, D: 1 }); + route.addNode("C", { A: 1, E: 1 }); + route.addNode("D", { B: 1 }); + route.addNode("E", { C: 1 }); + + 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", 2], + ["A->C", 2], + ["B->D", 3], + ["C->E", 3], + ]); + + // Check observed depths + for (const args of spy.args) { + const expectedDepth = expected.get(`${args[0].from}->${args[0].to}`); + demand(args[0].depth).to.equal(expectedDepth); + } + }); }); }); From 548f56c7a5b7fe07a61d4ae3e6085105cd9330b9 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Thu, 6 Nov 2025 16:58:03 +0100 Subject: [PATCH 8/9] test: add tests for expected weights --- test/Graph.test.js | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/test/Graph.test.js b/test/Graph.test.js index f8c3f98..43ba0fb 100644 --- a/test/Graph.test.js +++ b/test/Graph.test.js @@ -453,19 +453,17 @@ describe("Graph", () => { demand(found).be.true(); }); - it("calculates depth correctly across branches", () => { + it("calculates depth and weight correctly across branches", () => { // Build a small tree so each target node has a unique depth - // A (1) - // ├─ B (2) - // │ └─ D (3) - // └─ C (2) - // └─ E (3) + // 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: 1 }); - route.addNode("B", { A: 1, D: 1 }); - route.addNode("C", { A: 1, E: 1 }); - route.addNode("D", { B: 1 }); - route.addNode("E", { C: 1 }); + route.addNode("A", { B: 1, C: 2 }); + route.addNode("B", { D: 1 }); + route.addNode("C", { E: 3 }); const spy = sinon.spy(() => true); @@ -474,16 +472,17 @@ describe("Graph", () => { // Expected depths for each expanded edge const expected = new Map([ - ["A->B", 2], - ["A->C", 2], - ["B->D", 3], - ["C->E", 3], + ["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 expectedDepth = expected.get(`${args[0].from}->${args[0].to}`); - demand(args[0].depth).to.equal(expectedDepth); + 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); } }); }); From a1791bd4eabdc2df2566d1715a66afcc4f8b34b2 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Thu, 6 Nov 2025 17:03:51 +0100 Subject: [PATCH 9/9] refactor: fix types for can visit --- libs/index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/index.d.ts b/libs/index.d.ts index 45d51fc..d1670ff 100644 --- a/libs/index.d.ts +++ b/libs/index.d.ts @@ -141,10 +141,10 @@ interface PathOption { avoid?: any[] | undefined; maxCost?: number | undefined; maxNodes?: number | undefined; - canVisit?: ((arg: canVisitArg) => boolean) | undefined; + canVisit?: ((arg: CanVisitArg) => boolean) | undefined; } -interface canVisitArg { +interface CanVisitArg { from: string; to: string; cost: number;