mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-16 07:17:44 +02:00
2527 lines
107 KiB
TypeScript
2527 lines
107 KiB
TypeScript
import { assert, describe, it } from "@effect/vitest"
|
|
import { Equal, Graph, Hash, Option } from "effect"
|
|
|
|
const directed = <N, E>(
|
|
nodes: ReadonlyArray<N>,
|
|
edges: ReadonlyArray<readonly [Graph.NodeIndex, Graph.NodeIndex, E]>
|
|
) =>
|
|
Graph.directed<N, E>((mutable) => {
|
|
for (const node of nodes) Graph.addNode(mutable, node)
|
|
for (const [source, target, data] of edges) Graph.addEdge(mutable, source, target, data)
|
|
})
|
|
|
|
const undirected = <N, E>(
|
|
nodes: ReadonlyArray<N>,
|
|
edges: ReadonlyArray<readonly [Graph.NodeIndex, Graph.NodeIndex, E]>
|
|
) =>
|
|
Graph.undirected<N, E>((mutable) => {
|
|
for (const node of nodes) Graph.addNode(mutable, node)
|
|
for (const [source, target, data] of edges) Graph.addEdge(mutable, source, target, data)
|
|
})
|
|
|
|
const assertGraphError = (thunk: () => unknown, message: string): void => {
|
|
let error: unknown
|
|
try {
|
|
thunk()
|
|
} catch (cause) {
|
|
error = cause
|
|
}
|
|
assert.ok(error instanceof Graph.GraphError)
|
|
assert.strictEqual(error.message, message)
|
|
}
|
|
|
|
const assertSnapshot = <N, E, T extends Graph.Kind>(
|
|
graph: Graph.Graph<N, E, T> | Graph.MutableGraph<N, E, T>,
|
|
expected: Graph.Snapshot<N, E, T>
|
|
): void => {
|
|
assert.deepStrictEqual(Graph.toSnapshot(graph), expected)
|
|
}
|
|
|
|
const assertPath = <E>(actual: Option.Option<Graph.PathResult<E>>, expected: Graph.PathResult<E>): void => {
|
|
assert.deepStrictEqual(actual, Option.some(expected))
|
|
}
|
|
|
|
const assertComponents = (
|
|
actual: ReadonlyArray<ReadonlyArray<number>>,
|
|
expected: ReadonlyArray<ReadonlyArray<number>>
|
|
) => {
|
|
const canonicalize = (components: ReadonlyArray<ReadonlyArray<number>>) =>
|
|
components.map((component) => Array.from(component).sort((a, b) => a - b)).sort((a, b) => a[0] - b[0])
|
|
assert.deepStrictEqual(canonicalize(actual), canonicalize(expected))
|
|
}
|
|
|
|
const assertIndices = <N>(walker: Graph.NodeWalker<N>, expected: ReadonlyArray<number>): void => {
|
|
assert.deepStrictEqual(Array.from(Graph.indices(walker)), expected)
|
|
}
|
|
|
|
describe("Graph", () => {
|
|
describe("construction and protocols", () => {
|
|
it("constructs directed and undirected graphs", () => {
|
|
const directedGraph = Graph.make("directed")<string, number>()
|
|
const undirectedGraph = Graph.undirected<string, number>()
|
|
|
|
assert.strictEqual(directedGraph.type, "directed")
|
|
assert.strictEqual(undirectedGraph.type, "undirected")
|
|
assert.strictEqual(directedGraph.mutable, false)
|
|
assert.strictEqual(Graph.nodeCount(directedGraph), 0)
|
|
assert.strictEqual(Graph.edgeCount(undirectedGraph), 0)
|
|
})
|
|
|
|
it("rejects invalid runtime graph kinds", () => {
|
|
assertGraphError(
|
|
() => Graph.make("invalid" as Graph.Kind)<never, never>(),
|
|
"Graph type must be directed or undirected"
|
|
)
|
|
})
|
|
|
|
it("recognizes immutable and mutable graphs only", () => {
|
|
assert.strictEqual(Graph.isGraph(Graph.directed()), true)
|
|
assert.strictEqual(Graph.isGraph(Graph.beginMutation(Graph.undirected())), true)
|
|
for (const value of [{}, null, undefined, "graph", 1, []]) {
|
|
assert.strictEqual(Graph.isGraph(value), false)
|
|
}
|
|
})
|
|
|
|
it("supports stringification, piping, and node iteration in graph order", () => {
|
|
const graph = directed(["A", "B"], [[0, 1, 1]])
|
|
|
|
assert.strictEqual(String(graph), "Graph(directed, 2, 1)")
|
|
assert.strictEqual(graph.pipe(Graph.nodeCount), 2)
|
|
assert.deepStrictEqual(Array.from(graph), [[0, "A"], [1, "B"]])
|
|
const iterator = graph[Symbol.iterator]()
|
|
assert.deepStrictEqual(iterator.next(), { done: false, value: [0, "A"] })
|
|
assert.deepStrictEqual(iterator.next(), { done: false, value: [1, "B"] })
|
|
assert.deepStrictEqual(iterator.next(), { done: true, value: undefined })
|
|
})
|
|
})
|
|
|
|
describe("snapshots", () => {
|
|
it("round-trips sparse indexes, parallel edges, self-loops, and stored orientation", () => {
|
|
const snapshot = {
|
|
type: "undirected",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }],
|
|
edges: [
|
|
{ index: 3, source: 5, target: 2, data: "first" },
|
|
{ index: 7, source: 5, target: 2, data: "parallel" },
|
|
{ index: 11, source: 5, target: 5, data: "loop" }
|
|
]
|
|
} as const
|
|
const graph = Graph.fromSnapshot(snapshot)
|
|
|
|
assertSnapshot(graph, snapshot)
|
|
assert.deepStrictEqual(Graph.neighbors(graph, 2), [5])
|
|
assert.deepStrictEqual(Graph.neighbors(graph, 5), [2, 5])
|
|
assert.strictEqual(Equal.equals(Graph.fromSnapshot(Graph.toSnapshot(graph)), graph), true)
|
|
})
|
|
|
|
it("snapshots mutable state without exposing stored records", () => {
|
|
const graph = directed(["A", "B"], [[0, 1, 1]])
|
|
const mutable = Graph.beginMutation(graph)
|
|
Graph.updateNode(mutable, 0, () => "updated")
|
|
const snapshot = Graph.toSnapshot(mutable)
|
|
;(snapshot.nodes[0] as { index: number; data: string }).index = 1
|
|
;(snapshot.nodes[0] as { index: number; data: string }).data = "exposed"
|
|
;(snapshot.edges[0] as { source: number; data: number }).source = 1
|
|
;(snapshot.edges[0] as { source: number; data: number }).data = 2
|
|
|
|
assertSnapshot(mutable, {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "updated" }, { index: 1, data: "B" }],
|
|
edges: [{ index: 0, source: 0, target: 1, data: 1 }]
|
|
})
|
|
})
|
|
|
|
it("continues allocation after the highest active snapshot indexes", () => {
|
|
const mutable = Graph.beginMutation(Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }],
|
|
edges: [{ index: 4, source: 2, target: 5, data: 1 }]
|
|
}))
|
|
|
|
assert.strictEqual(Graph.addNode(mutable, "C"), 6)
|
|
assert.strictEqual(Graph.addEdge(mutable, 2, 5, 2), 5)
|
|
})
|
|
|
|
it("rejects malformed snapshot records and indexes", () => {
|
|
const cases: ReadonlyArray<readonly [() => unknown, string]> = [
|
|
[
|
|
() =>
|
|
Graph.fromSnapshot(
|
|
{ type: "invalid", nodes: [], edges: [] } as unknown as Graph.Snapshot<never, never, Graph.Kind>
|
|
),
|
|
"Snapshot type must be directed or undirected"
|
|
],
|
|
[
|
|
() => Graph.fromSnapshot({ type: "directed", nodes: new Array(1), edges: [] }),
|
|
"Node at position 0 must be defined"
|
|
],
|
|
[
|
|
() =>
|
|
Graph.fromSnapshot(
|
|
{ type: "directed", nodes: [null], edges: [] } as unknown as Graph.Snapshot<never, never, "directed">
|
|
),
|
|
"Node at position 0 must be defined"
|
|
],
|
|
[
|
|
() => Graph.fromSnapshot({ type: "directed", nodes: [{ index: -1, data: "A" }], edges: [] }),
|
|
"Node index at position 0 must be a non-negative safe integer"
|
|
],
|
|
[
|
|
() =>
|
|
Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: Number.MAX_SAFE_INTEGER + 1, data: "A" }],
|
|
edges: []
|
|
}),
|
|
"Node index at position 0 must be a non-negative safe integer"
|
|
],
|
|
[
|
|
() =>
|
|
Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 1, data: "A" }, { index: 1, data: "B" }],
|
|
edges: []
|
|
}),
|
|
"Node indexes must be strictly increasing"
|
|
],
|
|
[
|
|
() => Graph.fromSnapshot({ type: "directed", nodes: [{ index: 0, data: "A" }], edges: new Array(1) }),
|
|
"Edge at position 0 must be defined"
|
|
],
|
|
[
|
|
() =>
|
|
Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }],
|
|
edges: [{ index: 0.5, source: 0, target: 0, data: 1 }]
|
|
}),
|
|
"Edge index at position 0 must be a non-negative safe integer"
|
|
],
|
|
[
|
|
() =>
|
|
Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }],
|
|
edges: [{ index: 1, source: 0, target: 0, data: 1 }, { index: 1, source: 0, target: 0, data: 2 }]
|
|
}),
|
|
"Edge indexes must be strictly increasing"
|
|
],
|
|
[
|
|
() =>
|
|
Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }],
|
|
edges: [{ index: 0, source: -1, target: 0, data: 1 }]
|
|
}),
|
|
"Edge source at position 0 must be a non-negative safe integer"
|
|
],
|
|
[
|
|
() =>
|
|
Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }],
|
|
edges: [{ index: 0, source: 0, target: 0.5, data: 1 }]
|
|
}),
|
|
"Edge target at position 0 must be a non-negative safe integer"
|
|
],
|
|
[
|
|
() =>
|
|
Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 1, data: "A" }],
|
|
edges: [{ index: 0, source: 0, target: 1, data: 1 }]
|
|
}),
|
|
"Edge source 0 does not reference a node"
|
|
],
|
|
[
|
|
() =>
|
|
Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 1, data: "A" }],
|
|
edges: [{ index: 0, source: 1, target: 2, data: 1 }]
|
|
}),
|
|
"Edge target 2 does not reference a node"
|
|
]
|
|
]
|
|
|
|
for (const [run, message] of cases) assertGraphError(run, message)
|
|
})
|
|
|
|
it("rejects allocation after safe indexes are exhausted", () => {
|
|
const nodes = Graph.beginMutation(Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: Number.MAX_SAFE_INTEGER, data: "A" }],
|
|
edges: []
|
|
}))
|
|
const edges = Graph.beginMutation(Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }],
|
|
edges: [{ index: Number.MAX_SAFE_INTEGER, source: 0, target: 0, data: 1 }]
|
|
}))
|
|
|
|
assertGraphError(() => Graph.addNode(nodes, "B"), "Graph has exhausted safe node indexes")
|
|
assertGraphError(() => Graph.addEdge(edges, 0, 0, 2), "Graph has exhausted safe edge indexes")
|
|
})
|
|
})
|
|
|
|
describe("equality and hashing", () => {
|
|
it("gives equal immutable graphs equal hashes", () => {
|
|
const left = undirected(["A", "B"], [[0, 1, "first"], [0, 1, "second"], [0, 0, "loop"]])
|
|
const right = undirected(["A", "B"], [[1, 0, "first"], [1, 0, "second"], [0, 0, "loop"]])
|
|
|
|
assert.strictEqual(Equal.equals(left, right), true)
|
|
assert.strictEqual(Hash.hash(left), Hash.hash(right))
|
|
})
|
|
|
|
it("distinguishes undirected self-loops on different nodes", () => {
|
|
const onA = undirected(["A", "B"], [[0, 0, "loop"]])
|
|
const onB = undirected(["A", "B"], [[1, 1, "loop"]])
|
|
|
|
assert.strictEqual(Equal.equals(onA, onB), false)
|
|
assert.notStrictEqual(Hash.hash(onA), Hash.hash(onB))
|
|
})
|
|
|
|
it("distinguishes node payload, edge payload, missing edge, kind, and sparse indexes", () => {
|
|
const base = directed(["A", "B"], [[0, 1, "edge"]])
|
|
const cases: ReadonlyArray<Graph.Graph<unknown, unknown, Graph.Kind>> = [
|
|
directed(["changed", "B"], [[0, 1, "edge"]]),
|
|
directed(["A", "B"], [[0, 1, "changed"]]),
|
|
directed(["A", "B"], []),
|
|
undirected(["A", "B"], [[0, 1, "edge"]]),
|
|
Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }],
|
|
edges: [{ index: 3, source: 2, target: 5, data: "edge" }]
|
|
})
|
|
]
|
|
|
|
for (const candidate of cases) {
|
|
assert.strictEqual(Equal.equals(base, candidate), false)
|
|
assert.strictEqual(Equal.equals(candidate, base), false)
|
|
}
|
|
})
|
|
|
|
it("keeps directed endpoints and parallel-edge index pairing ordered", () => {
|
|
const directedLeft = directed(["A", "B"], [[0, 1, "edge"]])
|
|
const directedRight = directed(["A", "B"], [[1, 0, "edge"]])
|
|
const parallel = undirected(["A", "B"], [[0, 1, "first"], [0, 1, "second"]])
|
|
const reordered = undirected(["A", "B"], [[1, 0, "second"], [1, 0, "first"]])
|
|
|
|
assert.strictEqual(Equal.equals(directedLeft, directedRight), false)
|
|
assert.strictEqual(Equal.equals(parallel, reordered), false)
|
|
})
|
|
|
|
it("ignores removed trailing allocator history while preserving future allocation", () => {
|
|
const left = directed(["A", "B"], [[0, 1, "edge"]])
|
|
const right = Graph.directed<string, string>((mutable) => {
|
|
Graph.addNode(mutable, "A")
|
|
Graph.addNode(mutable, "B")
|
|
Graph.addEdge(mutable, 0, 1, "edge")
|
|
const node = Graph.addNode(mutable, "removed")
|
|
const edge = Graph.addEdge(mutable, 0, 1, "removed")
|
|
Graph.removeNode(mutable, node)
|
|
Graph.removeEdge(mutable, edge)
|
|
})
|
|
|
|
assert.strictEqual(Equal.equals(left, right), true)
|
|
assert.strictEqual(Hash.hash(left), Hash.hash(right))
|
|
Graph.mutate(left, (mutable) => {
|
|
assert.strictEqual(Graph.addNode(mutable, "next"), 2)
|
|
})
|
|
Graph.mutate(right, (mutable) => {
|
|
assert.strictEqual(Graph.addNode(mutable, "next"), 3)
|
|
})
|
|
Graph.mutate(left, (mutable) => {
|
|
assert.strictEqual(Graph.addEdge(mutable, 0, 1, "next"), 1)
|
|
})
|
|
Graph.mutate(right, (mutable) => {
|
|
assert.strictEqual(Graph.addEdge(mutable, 0, 1, "next"), 2)
|
|
})
|
|
})
|
|
|
|
it("uses reference equality for mutable graphs and structural equality after finalization", () => {
|
|
const graph = directed(["A", "B"], [[0, 1, "edge"]])
|
|
const left = Graph.beginMutation(graph)
|
|
const right = Graph.beginMutation(graph)
|
|
const hash = Hash.hash(left)
|
|
|
|
assert.strictEqual(Equal.equals(left, right), false)
|
|
Graph.addNode(left, "C")
|
|
assert.strictEqual(Hash.hash(left), hash)
|
|
const finalized = Graph.endMutation(left)
|
|
assert.strictEqual(Equal.equals(finalized, directed(["A", "B", "C"], [[0, 1, "edge"]])), true)
|
|
})
|
|
|
|
it("supports undefined node and edge payloads structurally", () => {
|
|
const make = () => directed<undefined, undefined>([undefined, undefined], [[0, 1, undefined]])
|
|
assert.strictEqual(Equal.equals(make(), make()), true)
|
|
assert.strictEqual(Hash.hash(make()), Hash.hash(make()))
|
|
})
|
|
})
|
|
|
|
describe("mutation lifecycle", () => {
|
|
it("isolates mutable changes from the source and finalizes to a new immutable graph", () => {
|
|
const source = directed(["A", "B"], [[0, 1, 1]])
|
|
const mutable = Graph.beginMutation(source)
|
|
Graph.addNode(mutable, "C")
|
|
const result = Graph.endMutation(mutable)
|
|
|
|
assert.strictEqual(Graph.nodeCount(source), 2)
|
|
assert.strictEqual(Graph.nodeCount(result), 3)
|
|
assert.notStrictEqual(result, source)
|
|
assert.strictEqual(result.mutable, false)
|
|
})
|
|
|
|
it("supports data-first and data-last scoped mutation", () => {
|
|
const graph = Graph.directed<string, number>()
|
|
const first = Graph.mutate(graph, (mutable) => {
|
|
Graph.addNode(mutable, "A")
|
|
})
|
|
const last = graph.pipe(Graph.mutate((mutable) => {
|
|
Graph.addNode(mutable, "A")
|
|
}))
|
|
|
|
assertSnapshot(first, { type: "directed", nodes: [{ index: 0, data: "A" }], edges: [] })
|
|
assertSnapshot(last, { type: "directed", nodes: [{ index: 0, data: "A" }], edges: [] })
|
|
})
|
|
|
|
it("finalizes retained handles when callbacks return or throw", () => {
|
|
let returned: Graph.MutableDirectedGraph<string, number> | undefined
|
|
Graph.directed<string, number>((mutable) => {
|
|
returned = mutable
|
|
})
|
|
assertGraphError(() => Graph.addNode(returned!, "late"), "Graph is not mutable")
|
|
|
|
let thrown: Graph.MutableDirectedGraph<string, number> | undefined
|
|
const cause = new Error("boom")
|
|
let actual: unknown
|
|
try {
|
|
Graph.mutate(Graph.directed<string, number>(), (mutable) => {
|
|
thrown = mutable
|
|
throw cause
|
|
})
|
|
} catch (error) {
|
|
actual = error
|
|
}
|
|
assert.strictEqual(actual, cause)
|
|
assertGraphError(() => Graph.addNode(thrown!, "late"), "Graph is not mutable")
|
|
})
|
|
|
|
it("rejects normal callback return after manual finalization", () => {
|
|
assertGraphError(() => {
|
|
Graph.directed<string, number>((mutable) => {
|
|
Graph.endMutation(mutable)
|
|
})
|
|
}, "Graph is not mutable")
|
|
assertGraphError(() => {
|
|
Graph.mutate(Graph.directed<string, number>(), (mutable) => {
|
|
Graph.endMutation(mutable)
|
|
})
|
|
}, "Graph is not mutable")
|
|
})
|
|
|
|
it("preserves callback errors after manual finalization", () => {
|
|
const cause = new Error("callback failure")
|
|
for (
|
|
const run of [
|
|
() =>
|
|
Graph.directed<string, number>((mutable) => {
|
|
Graph.endMutation(mutable)
|
|
throw cause
|
|
}),
|
|
() =>
|
|
Graph.mutate(Graph.directed<string, number>(), (mutable) => {
|
|
Graph.endMutation(mutable)
|
|
throw cause
|
|
})
|
|
]
|
|
) {
|
|
let actual: unknown
|
|
try {
|
|
run()
|
|
} catch (error) {
|
|
actual = error
|
|
}
|
|
assert.strictEqual(actual, cause)
|
|
}
|
|
})
|
|
|
|
it("rejects every mutation entry point on a finalized handle", () => {
|
|
const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]]))
|
|
Graph.endMutation(mutable)
|
|
const mutations: ReadonlyArray<() => unknown> = [
|
|
() => Graph.addNode(mutable, "C"),
|
|
() => Graph.addEdge(mutable, 1, 0, 2),
|
|
() => Graph.updateNode(mutable, 0, () => "updated"),
|
|
() => Graph.updateEdge(mutable, 0, () => 2),
|
|
() => Graph.removeNode(mutable, 0),
|
|
() => Graph.removeNodes(mutable, [0]),
|
|
() => Graph.removeEdge(mutable, 0),
|
|
() => Graph.removeEdges(mutable, [0]),
|
|
() => Graph.mapNodes(mutable, () => "mapped"),
|
|
() => Graph.mapEdges(mutable, () => 3),
|
|
() => Graph.filterMapNodes(mutable, () => Option.none()),
|
|
() => Graph.filterMapEdges(mutable, () => Option.none()),
|
|
() => Graph.filterNodes(mutable, () => true),
|
|
() => Graph.filterEdges(mutable, () => true),
|
|
() => Graph.reverse(mutable),
|
|
() => Graph.endMutation(mutable)
|
|
]
|
|
for (const mutation of mutations) assertGraphError(mutation, "Graph is not mutable")
|
|
})
|
|
|
|
it("rejects nested mutation or finalization from transformation callbacks", () => {
|
|
const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]]))
|
|
const mutations: ReadonlyArray<() => unknown> = [
|
|
() => Graph.addNode(mutable, "C"),
|
|
() => Graph.addEdge(mutable, 1, 0, 2),
|
|
() => Graph.updateNode(mutable, 0, () => "updated"),
|
|
() => Graph.updateEdge(mutable, 0, () => 2),
|
|
() => Graph.removeNode(mutable, 0),
|
|
() => Graph.removeNodes(mutable, [0]),
|
|
() => Graph.removeEdge(mutable, 0),
|
|
() => Graph.removeEdges(mutable, [0]),
|
|
() => Graph.mapNodes(mutable, (node) => node),
|
|
() => Graph.mapEdges(mutable, (edge) => edge),
|
|
() => Graph.filterMapNodes(mutable, (node) => Option.some(node)),
|
|
() => Graph.filterMapEdges(mutable, (edge) => Option.some(edge)),
|
|
() => Graph.filterNodes(mutable, () => true),
|
|
() => Graph.filterEdges(mutable, () => true),
|
|
() => Graph.reverse(mutable),
|
|
() => Graph.endMutation(mutable)
|
|
]
|
|
|
|
for (const mutation of mutations) {
|
|
assertGraphError(() =>
|
|
Graph.updateNode(mutable, 0, (node) => {
|
|
mutation()
|
|
return node
|
|
}), "Cannot mutate graph during a transformation")
|
|
}
|
|
assertSnapshot(mutable, {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }],
|
|
edges: [{ index: 0, source: 0, target: 1, data: 1 }]
|
|
})
|
|
})
|
|
|
|
it("guards every transformation callback against nested mutation", () => {
|
|
const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]]))
|
|
const mutate = () => Graph.addNode(mutable, "C")
|
|
const transformations: ReadonlyArray<() => unknown> = [
|
|
() =>
|
|
Graph.updateNode(mutable, 0, (node) => {
|
|
mutate()
|
|
return node
|
|
}),
|
|
() =>
|
|
Graph.updateEdge(mutable, 0, (edge) => {
|
|
mutate()
|
|
return edge
|
|
}),
|
|
() =>
|
|
Graph.mapNodes(mutable, (node) => {
|
|
mutate()
|
|
return node
|
|
}),
|
|
() =>
|
|
Graph.mapEdges(mutable, (edge) => {
|
|
mutate()
|
|
return edge
|
|
}),
|
|
() =>
|
|
Graph.filterMapNodes(mutable, (node) => {
|
|
mutate()
|
|
return Option.some(node)
|
|
}),
|
|
() =>
|
|
Graph.filterMapEdges(mutable, (edge) => {
|
|
mutate()
|
|
return Option.some(edge)
|
|
}),
|
|
() =>
|
|
Graph.filterNodes(mutable, () => {
|
|
mutate()
|
|
return true
|
|
}),
|
|
() =>
|
|
Graph.filterEdges(mutable, () => {
|
|
mutate()
|
|
return true
|
|
})
|
|
]
|
|
|
|
for (const transformation of transformations) {
|
|
assertGraphError(transformation, "Cannot mutate graph during a transformation")
|
|
}
|
|
})
|
|
|
|
it("guards read callbacks against nested mutation", () => {
|
|
const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]]))
|
|
const mutate = () => Graph.addNode(mutable, "C")
|
|
const operations: ReadonlyArray<() => unknown> = [
|
|
() =>
|
|
Graph.findNode(mutable, () => {
|
|
Graph.findNode(mutable, () => false)
|
|
mutate()
|
|
return false
|
|
}),
|
|
() => Graph.findNodes(mutable, () => (mutate(), false)),
|
|
() => Graph.findEdge(mutable, () => (mutate(), false)),
|
|
() => Graph.findEdges(mutable, () => (mutate(), false)),
|
|
() => Graph.toGraphViz(mutable, { edgeLabel: (edge) => (mutate(), String(edge)) }),
|
|
() => Graph.toMermaid(mutable, { nodeShape: () => (mutate(), "rectangle") }),
|
|
() => Graph.maximumFlow(mutable, { source: 0, target: 1, capacity: (edge) => (mutate(), edge) }),
|
|
() => Graph.minimumCut(mutable, { source: 0, target: 1, capacity: (edge) => (mutate(), edge) }),
|
|
() => Graph.dijkstra(mutable, { source: 0, target: 1, cost: (edge) => (mutate(), edge) }),
|
|
() => Graph.floydWarshall(mutable, (edge) => (mutate(), edge)),
|
|
() => Graph.astar(mutable, { source: 0, target: 1, cost: (edge) => (mutate(), edge), heuristic: () => 0 }),
|
|
() => Graph.astar(mutable, { source: 0, target: 1, cost: (edge) => edge, heuristic: () => (mutate(), 0) }),
|
|
() => Graph.bellmanFord(mutable, { source: 0, target: 1, cost: (edge) => (mutate(), edge) }),
|
|
() =>
|
|
Array.from(Graph.allShortestPaths(mutable, {
|
|
source: 0,
|
|
target: 1,
|
|
cost: (edge) => (mutate(), edge)
|
|
})),
|
|
() => Array.from(Graph.dfs(mutable, { start: [0] }).visit(() => mutate())),
|
|
() => Array.from(Graph.nodes(mutable).visit(() => mutate())),
|
|
() => Array.from(Graph.edges(mutable).visit(() => mutate())),
|
|
() => Array.from(Graph.externals(mutable).visit(() => mutate()))
|
|
]
|
|
|
|
for (const operation of operations) {
|
|
assertGraphError(operation, "Cannot mutate graph during a transformation")
|
|
}
|
|
|
|
const undirectedMutable = Graph.beginMutation(undirected(["A", "B"], [[0, 1, 1]]))
|
|
assertGraphError(() =>
|
|
Graph.minimumSpanningForest(undirectedMutable, (edge) => {
|
|
Graph.addNode(undirectedMutable, "C")
|
|
return edge
|
|
}), "Cannot mutate graph during a transformation")
|
|
})
|
|
})
|
|
|
|
describe("node operations", () => {
|
|
it("allocates stable indexes and supports lookup, membership, and count", () => {
|
|
const mutable = Graph.beginMutation(Graph.directed<string | undefined, never>())
|
|
assert.strictEqual(Graph.addNode(mutable, undefined), 0)
|
|
assert.strictEqual(Graph.addNode(mutable, "B"), 1)
|
|
|
|
assert.strictEqual(Graph.nodeCount(mutable), 2)
|
|
assert.strictEqual(Graph.hasNode(mutable, 0), true)
|
|
assert.strictEqual(Graph.hasNode(mutable, 2), false)
|
|
assert.deepStrictEqual(Graph.getNode(mutable, 0), Option.some(undefined))
|
|
assert.deepStrictEqual(Graph.getNode(0)(mutable), Option.some(undefined))
|
|
assert.deepStrictEqual(Graph.getNode(mutable, 2), Option.none())
|
|
})
|
|
|
|
it("updates existing payloads and ignores missing indexes", () => {
|
|
const mutable = Graph.beginMutation(directed<string | undefined, never>([undefined, "B"], []))
|
|
Graph.updateNode(mutable, 0, () => "A")
|
|
Graph.updateNode(mutable, 1, () => undefined)
|
|
Graph.updateNode(mutable, 99, () => "missing")
|
|
|
|
assert.deepStrictEqual(Array.from(mutable), [[0, "A"], [1, undefined]])
|
|
})
|
|
|
|
it("finds the first and all matching nodes in graph order", () => {
|
|
const graph = directed<string | undefined, never>([undefined, "B", undefined], [])
|
|
|
|
assert.deepStrictEqual(Graph.findNode(graph, (node) => node === undefined), Option.some(0))
|
|
assert.deepStrictEqual(Graph.findNode((node: string | undefined) => node === "missing")(graph), Option.none())
|
|
assert.deepStrictEqual(Graph.findNodes(graph, (node) => node === undefined), [0, 2])
|
|
})
|
|
})
|
|
|
|
describe("edge operations", () => {
|
|
it("allocates parallel edges and self-loops with exact stored orientation", () => {
|
|
const graph = Graph.undirected<string, string>((mutable) => {
|
|
Graph.addNode(mutable, "A")
|
|
Graph.addNode(mutable, "B")
|
|
assert.strictEqual(Graph.addEdge(mutable, 1, 0, "first"), 0)
|
|
assert.strictEqual(Graph.addEdge(mutable, 1, 0, "parallel"), 1)
|
|
assert.strictEqual(Graph.addEdge(mutable, 0, 0, "loop"), 2)
|
|
})
|
|
|
|
assertSnapshot(graph, {
|
|
type: "undirected",
|
|
nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }],
|
|
edges: [
|
|
{ index: 0, source: 1, target: 0, data: "first" },
|
|
{ index: 1, source: 1, target: 0, data: "parallel" },
|
|
{ index: 2, source: 0, target: 0, data: "loop" }
|
|
]
|
|
})
|
|
})
|
|
|
|
it("rejects missing edge endpoints", () => {
|
|
const mutable = Graph.beginMutation(directed<string, number>(["A"], []))
|
|
assertGraphError(() => Graph.addEdge(mutable, 1, 0, 1), "Node 1 does not exist")
|
|
assertGraphError(() => Graph.addEdge(mutable, 0, 1, 1), "Node 1 does not exist")
|
|
})
|
|
|
|
it("gets and updates undefined edge data without exposing stored records", () => {
|
|
const mutable = Graph.beginMutation(directed<string, number | undefined>(["A", "B"], [[0, 1, undefined]]))
|
|
const fromGetter = Option.getOrThrow(Graph.getEdge(mutable, 0))
|
|
const fromWalker = Array.from(Graph.values(Graph.edges(mutable)))[0]
|
|
;(fromGetter as { source: number; data: number | undefined }).source = 1
|
|
;(fromGetter as { source: number; data: number | undefined }).data = 1
|
|
;(fromWalker as { target: number; data: number | undefined }).target = 0
|
|
;(fromWalker as { target: number; data: number | undefined }).data = 1
|
|
|
|
assert.deepStrictEqual(Graph.getEdge(mutable, 0), Option.some({ source: 0, target: 1, data: undefined }))
|
|
assertSnapshot(mutable, {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }],
|
|
edges: [{ index: 0, source: 0, target: 1, data: undefined }]
|
|
})
|
|
assert.deepStrictEqual(Graph.neighbors(mutable, 0), [1])
|
|
assert.deepStrictEqual(Graph.neighbors(mutable, 1), [])
|
|
|
|
Graph.updateEdge(mutable, 0, () => 2)
|
|
Graph.updateEdge(mutable, 99, () => 3)
|
|
|
|
assert.deepStrictEqual(Graph.getEdge(mutable, 0), Option.some({ source: 0, target: 1, data: 2 }))
|
|
assert.deepStrictEqual(Graph.getEdge(99)(mutable), Option.none())
|
|
assertSnapshot(mutable, {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }],
|
|
edges: [{ index: 0, source: 0, target: 1, data: 2 }]
|
|
})
|
|
assert.deepStrictEqual(Graph.neighbors(mutable, 0), [1])
|
|
})
|
|
|
|
it("removes parallel undirected edges and self-loops independently", () => {
|
|
const mutable = Graph.beginMutation(undirected(["A", "B"], [[1, 0, "first"], [0, 1, "second"], [1, 1, "loop"]]))
|
|
Graph.removeEdge(mutable, 0)
|
|
Graph.removeEdge(mutable, 2)
|
|
Graph.removeEdge(mutable, 99)
|
|
|
|
assertSnapshot(mutable, {
|
|
type: "undirected",
|
|
nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }],
|
|
edges: [{ index: 1, source: 0, target: 1, data: "second" }]
|
|
})
|
|
assert.deepStrictEqual(Graph.neighbors(mutable, 0), [1])
|
|
assert.deepStrictEqual(Graph.neighbors(mutable, 1), [0])
|
|
})
|
|
|
|
it("removes undirected nodes with reversed edges, parallel edges, and self-loops", () => {
|
|
const mutable = Graph.beginMutation(undirected(["A", "B", "C"], [
|
|
[1, 0, "reverse"],
|
|
[0, 1, "parallel"],
|
|
[1, 1, "loop"],
|
|
[2, 0, "keep"]
|
|
]))
|
|
Graph.removeNode(mutable, 1)
|
|
|
|
assertSnapshot(mutable, {
|
|
type: "undirected",
|
|
nodes: [{ index: 0, data: "A" }, { index: 2, data: "C" }],
|
|
edges: [{ index: 3, source: 2, target: 0, data: "keep" }]
|
|
})
|
|
})
|
|
|
|
it("collects graph-backed iterables before bulk removal", () => {
|
|
const edges = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1], [1, 0, 2]]))
|
|
Graph.removeEdges(edges, Graph.indices(Graph.edges(edges)))
|
|
assert.strictEqual(Graph.edgeCount(edges), 0)
|
|
|
|
const nodes = Graph.beginMutation(directed<string, never>(["A", "B"], []))
|
|
Graph.removeNodes(nodes, Graph.indices(Graph.nodes(nodes)))
|
|
assert.strictEqual(Graph.nodeCount(nodes), 0)
|
|
})
|
|
|
|
it("keeps caches fresh when bulk-removal iterables query the graph", () => {
|
|
const edges = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]]))
|
|
Graph.removeEdges(edges, {
|
|
*[Symbol.iterator]() {
|
|
assert.strictEqual(Graph.hasPath(edges, 0, 1), true)
|
|
yield 0
|
|
}
|
|
})
|
|
assert.strictEqual(Graph.hasPath(edges, 0, 1), false)
|
|
|
|
const nodes = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1], [1, 0, 2]]))
|
|
Graph.removeNodes(nodes, {
|
|
*[Symbol.iterator]() {
|
|
assert.strictEqual(Graph.isAcyclic(nodes), false)
|
|
yield 1
|
|
}
|
|
})
|
|
assert.strictEqual(Graph.isAcyclic(nodes), true)
|
|
})
|
|
|
|
it("rejects finalization from bulk-removal iterables", () => {
|
|
const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]]))
|
|
assertGraphError(() =>
|
|
Graph.removeEdges(mutable, {
|
|
*[Symbol.iterator]() {
|
|
Graph.endMutation(mutable)
|
|
yield 0
|
|
}
|
|
}), "Cannot mutate graph during a transformation")
|
|
assert.strictEqual(mutable.mutable, true)
|
|
assert.strictEqual(Graph.edgeCount(mutable), 1)
|
|
})
|
|
})
|
|
|
|
describe("transformations", () => {
|
|
it("maps nodes and edges while preserving indexes and structure", () => {
|
|
const mutable = Graph.beginMutation(directed(["a", "b"], [[0, 1, 2]]))
|
|
Graph.mapNodes(mutable, (node) => node.toUpperCase())
|
|
Graph.mapEdges(mutable, (edge) => edge * 3)
|
|
|
|
assertSnapshot(mutable, {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }],
|
|
edges: [{ index: 0, source: 0, target: 1, data: 6 }]
|
|
})
|
|
})
|
|
|
|
it("filter-maps nodes and removes incident edges", () => {
|
|
const mutable = Graph.beginMutation(directed([1, 2, 3], [[0, 1, "remove"], [1, 2, "remove"], [0, 2, "keep"]]))
|
|
Graph.filterMapNodes(mutable, (node) => node === 2 ? Option.none() : Option.some(node * 10))
|
|
|
|
assertSnapshot(mutable, {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: 10 }, { index: 2, data: 30 }],
|
|
edges: [{ index: 2, source: 0, target: 2, data: "keep" }]
|
|
})
|
|
})
|
|
|
|
it("filter-maps edges without removing nodes", () => {
|
|
const mutable = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2], [2, 0, 3]]))
|
|
Graph.filterMapEdges(mutable, (edge) => edge % 2 === 0 ? Option.none() : Option.some(edge * 10))
|
|
|
|
assertSnapshot(mutable, {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }, { index: 2, data: "C" }],
|
|
edges: [{ index: 0, source: 0, target: 1, data: 10 }, { index: 2, source: 2, target: 0, data: 30 }]
|
|
})
|
|
})
|
|
|
|
it("filters nodes and removes their incident edges", () => {
|
|
const nodes = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2], [0, 2, 3]]))
|
|
Graph.filterNodes(nodes, (node) => node !== "B")
|
|
assertSnapshot(nodes, {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }, { index: 2, data: "C" }],
|
|
edges: [{ index: 2, source: 0, target: 2, data: 3 }]
|
|
})
|
|
})
|
|
|
|
it("filters edges without removing nodes", () => {
|
|
const edges = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2], [0, 2, 3]]))
|
|
Graph.filterEdges(edges, (edge) => edge % 2 === 1)
|
|
assertSnapshot(edges, {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }, { index: 2, data: "C" }],
|
|
edges: [{ index: 0, source: 0, target: 1, data: 1 }, { index: 2, source: 0, target: 2, data: 3 }]
|
|
})
|
|
})
|
|
|
|
it("exposes earlier bulk writes to later callbacks", () => {
|
|
const nodes = Graph.beginMutation(directed<string, never>(["a", "b"], []))
|
|
const nodeStates: Array<Array<string>> = []
|
|
Graph.mapNodes(nodes, (node) => {
|
|
nodeStates.push(Array.from(Graph.values(Graph.nodes(nodes))))
|
|
return node.toUpperCase()
|
|
})
|
|
assert.deepStrictEqual(nodeStates, [["a", "b"], ["A", "b"]])
|
|
|
|
const edges = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2]]))
|
|
const edgeStates: Array<Array<number>> = []
|
|
Graph.mapEdges(edges, (edge) => {
|
|
edgeStates.push(Array.from(Graph.values(Graph.edges(edges)), (value) => value.data))
|
|
return edge * 2
|
|
})
|
|
assert.deepStrictEqual(edgeStates, [[1, 2], [2, 2]])
|
|
})
|
|
|
|
it("publishes updated callback values to subsequent graph reads", () => {
|
|
const nodes = Graph.beginMutation(directed<string, never>(["old"], []))
|
|
Graph.updateNode(nodes, 0, () => {
|
|
assert.deepStrictEqual(Array.from(Graph.values(Graph.bfs(nodes, { start: [0] }))), ["old"])
|
|
return "new"
|
|
})
|
|
assert.deepStrictEqual(Array.from(Graph.values(Graph.bfs(nodes, { start: [0] }))), ["new"])
|
|
|
|
const edges = Graph.beginMutation(directed(["source", "target"], [[0, 1, 1]]))
|
|
Graph.updateEdge(edges, 0, () => {
|
|
assert.deepStrictEqual(Array.from(Graph.simplePaths(edges, { source: 0, target: 1 }))[0].costs, [1])
|
|
return 2
|
|
})
|
|
assert.deepStrictEqual(Array.from(Graph.simplePaths(edges, { source: 0, target: 1 }))[0].costs, [2])
|
|
})
|
|
|
|
it("reverses directed edges and leaves undirected stored orientation unchanged", () => {
|
|
const directedMutable = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2]]))
|
|
Graph.reverse(directedMutable)
|
|
Graph.addEdge(directedMutable, 0, 1, 3)
|
|
assert.deepStrictEqual(Graph.toSnapshot(directedMutable).edges, [
|
|
{ index: 0, source: 1, target: 0, data: 1 },
|
|
{ index: 1, source: 2, target: 1, data: 2 },
|
|
{ index: 2, source: 0, target: 1, data: 3 }
|
|
])
|
|
assert.deepStrictEqual(Graph.neighbors(directedMutable, 2), [1])
|
|
assert.strictEqual(Graph.hasEdge(directedMutable, 1, 0), true)
|
|
assert.strictEqual(Graph.hasEdge(directedMutable, 0, 1), true)
|
|
|
|
const undirectedMutable = Graph.beginMutation(undirected(["A", "B"], [[1, 0, 1]]))
|
|
Graph.reverse(undirectedMutable)
|
|
assert.deepStrictEqual(Graph.toSnapshot(undirectedMutable).edges, [{ index: 0, source: 1, target: 0, data: 1 }])
|
|
})
|
|
})
|
|
|
|
describe("set operations", () => {
|
|
type Node = { readonly id: string; readonly label: string }
|
|
const left = () =>
|
|
directed<Node, string>(
|
|
[{ id: "a", label: "A1" }, { id: "b", label: "B1" }, { id: "c", label: "C1" }],
|
|
[[0, 1, "left"], [1, 2, "shared"]]
|
|
)
|
|
const right = () =>
|
|
directed<Node, string>(
|
|
[{ id: "b", label: "B2" }, { id: "c", label: "C2" }, { id: "d", label: "D2" }],
|
|
[[0, 1, "shared"], [1, 2, "right"]]
|
|
)
|
|
const identity = { nodeIdentity: (node: Node) => node.id }
|
|
const semantic = (graph: Graph.Graph<Node, string>) => {
|
|
const nodes = new Map(Array.from(graph, ([index, node]) => [index, node]))
|
|
return {
|
|
nodes: Array.from(nodes.values(), (node) => `${node.id}:${node.label}`).sort(),
|
|
edges: Array.from(
|
|
Graph.values(Graph.edges(graph)),
|
|
(edge) => `${nodes.get(edge.source)!.id}->${nodes.get(edge.target)!.id}:${edge.data}`
|
|
).sort()
|
|
}
|
|
}
|
|
|
|
it("composes, intersects, differs, and symmetrically differs by projected identity", () => {
|
|
const composed = {
|
|
nodes: ["a:A1", "b:B2", "c:C2", "d:D2"],
|
|
edges: ["a->b:left", "b->c:shared", "c->d:right"]
|
|
}
|
|
const intersected = {
|
|
nodes: ["b:B1", "c:C1"],
|
|
edges: ["b->c:shared"]
|
|
}
|
|
const differed = {
|
|
nodes: ["a:A1", "b:B1", "c:C1"],
|
|
edges: ["a->b:left"]
|
|
}
|
|
const symmetric = {
|
|
nodes: ["a:A1", "b:B2", "c:C2", "d:D2"],
|
|
edges: ["a->b:left", "c->d:right"]
|
|
}
|
|
assert.deepStrictEqual(semantic(Graph.compose(left(), right(), identity)), composed)
|
|
assert.deepStrictEqual(semantic(Graph.compose(right(), identity)(left())), composed)
|
|
assert.deepStrictEqual(semantic(Graph.intersection(left(), right(), identity)), intersected)
|
|
assert.deepStrictEqual(semantic(Graph.intersection(right(), identity)(left())), intersected)
|
|
assert.deepStrictEqual(semantic(Graph.difference(left(), right(), identity)), differed)
|
|
assert.deepStrictEqual(semantic(Graph.difference(right(), identity)(left())), differed)
|
|
assert.deepStrictEqual(semantic(Graph.symmetricDifference(left(), right(), identity)), symmetric)
|
|
assert.deepStrictEqual(semantic(Graph.symmetricDifference(right(), identity)(left())), symmetric)
|
|
})
|
|
|
|
it("supports Effect Equal and Hash node identities", () => {
|
|
class NodeKey implements Equal.Equal {
|
|
constructor(readonly id: string) {}
|
|
[Equal.symbol](that: Equal.Equal): boolean {
|
|
return that instanceof NodeKey && this.id === that.id
|
|
}
|
|
[Hash.symbol](): number {
|
|
return Hash.string(this.id)
|
|
}
|
|
}
|
|
|
|
const result = Graph.compose(left(), right(), { nodeIdentity: (node) => new NodeKey(node.id) })
|
|
assert.deepStrictEqual(semantic(result), {
|
|
nodes: ["a:A1", "b:B2", "c:C2", "d:D2"],
|
|
edges: ["a->b:left", "b->c:shared", "c->d:right"]
|
|
})
|
|
})
|
|
|
|
it("coalesces duplicate identities to the last payload and redirects edges", () => {
|
|
const graph = directed<Node, string>([{ id: "a", label: "first" }, { id: "a", label: "last" }], [[
|
|
0,
|
|
1,
|
|
"edge"
|
|
]])
|
|
const result = Graph.compose(graph, Graph.directed<Node, string>(), identity)
|
|
const edge = Array.from(Graph.values(Graph.edges(result)))[0]
|
|
|
|
assert.deepStrictEqual(Array.from(Graph.values(Graph.nodes(result))), [{ id: "a", label: "last" }])
|
|
assert.strictEqual(edge.source, edge.target)
|
|
})
|
|
|
|
it("uses right edge payloads for custom-identity compose and intersection", () => {
|
|
type Edge = { readonly id: string; readonly label: string }
|
|
const left = directed<string, Edge>(["A", "B"], [[0, 1, { id: "shared", label: "left" }]])
|
|
const right = directed<string, Edge>(["A", "B"], [[0, 1, { id: "shared", label: "right" }]])
|
|
const options = { edgeIdentity: (edge: Edge) => edge.id }
|
|
|
|
assert.strictEqual(
|
|
Array.from(Graph.values(Graph.edges(Graph.compose(left, right, options))))[0].data.label,
|
|
"right"
|
|
)
|
|
assert.strictEqual(
|
|
Array.from(Graph.values(Graph.edges(Graph.intersection(left, right, options))))[0].data.label,
|
|
"right"
|
|
)
|
|
})
|
|
|
|
it("includes edge data in the default edge identity", () => {
|
|
const first = directed(["A", "B"], [[0, 1, "left"]])
|
|
const second = directed(["A", "B"], [[0, 1, "right"]])
|
|
|
|
assert.strictEqual(Graph.edgeCount(Graph.compose(first, second)), 2)
|
|
assert.strictEqual(Graph.edgeCount(Graph.intersection(first, second)), 0)
|
|
assert.strictEqual(Graph.edgeCount(Graph.difference(first, second)), 1)
|
|
assert.strictEqual(Graph.edgeCount(Graph.symmetricDifference(first, second)), 2)
|
|
})
|
|
|
|
it("uses undefined node data as the default node identity", () => {
|
|
assert.strictEqual(
|
|
Graph.nodeCount(Graph.compose(directed<undefined, never>([undefined], []), directed([undefined], []))),
|
|
1
|
|
)
|
|
})
|
|
|
|
it("treats equal parallel edges as set members while difference preserves unmatched occurrences", () => {
|
|
const parallel = directed(["A", "B"], [[0, 1, "same"], [0, 1, "same"]])
|
|
const one = directed(["A", "B"], [[0, 1, "same"]])
|
|
const empty = Graph.directed<string, string>()
|
|
|
|
assert.strictEqual(Graph.edgeCount(Graph.compose(parallel, empty)), 1)
|
|
assert.strictEqual(Graph.edgeCount(Graph.intersection(parallel, one)), 1)
|
|
assert.strictEqual(Graph.edgeCount(Graph.difference(parallel, empty)), 2)
|
|
assert.strictEqual(Graph.edgeCount(Graph.difference(parallel, one)), 0)
|
|
assert.strictEqual(Graph.edgeCount(Graph.symmetricDifference(parallel, empty)), 1)
|
|
})
|
|
|
|
it("matches undirected identities independent of stored orientation", () => {
|
|
const first = undirected(["A", "B"], [[0, 1, "same"]])
|
|
const second = undirected(["B", "A"], [[0, 1, "same"]])
|
|
assert.strictEqual(Graph.edgeCount(Graph.intersection(first, second)), 1)
|
|
assert.strictEqual(Graph.edgeCount(Graph.difference(first, second)), 0)
|
|
})
|
|
|
|
it("rejects runtime kind mismatches for every binary set operation", () => {
|
|
const first = Graph.directed<string, string>() as Graph.Graph<string, string, Graph.Kind>
|
|
const second = Graph.undirected<string, string>() as Graph.Graph<string, string, Graph.Kind>
|
|
const operations: ReadonlyArray<() => unknown> = [
|
|
() => Graph.compose(first, second),
|
|
() => Graph.intersection(first, second),
|
|
() => Graph.difference(first, second),
|
|
() => Graph.symmetricDifference(first, second),
|
|
() => Graph.sum(first, second)
|
|
]
|
|
for (const operation of operations) {
|
|
assertGraphError(operation, "Cannot combine directed and undirected graphs")
|
|
}
|
|
})
|
|
|
|
it("builds directed and undirected complements without self-loops", () => {
|
|
assertSnapshot(Graph.complement(directed(["A", "B"], [[0, 1, "existing"]]), (a, b) => `${a}-${b}`), {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }],
|
|
edges: [{ index: 0, source: 1, target: 0, data: "B-A" }]
|
|
})
|
|
assertSnapshot(Graph.complement(undirected(["A", "B", "C"], [[0, 1, "existing"]]), (a, b) => `${a}-${b}`), {
|
|
type: "undirected",
|
|
nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }, { index: 2, data: "C" }],
|
|
edges: [{ index: 0, source: 0, target: 2, data: "A-C" }, { index: 1, source: 1, target: 2, data: "B-C" }]
|
|
})
|
|
})
|
|
|
|
it("returns induced neighborhoods and validates radius", () => {
|
|
const graph = directed(["A", "B", "C", "D"], [[0, 1, "AB"], [1, 2, "BC"], [2, 1, "CB"], [2, 3, "CD"]])
|
|
assertSnapshot(Graph.neighborhood(graph, 1, { radius: 1 }), {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "B" }, { index: 1, data: "C" }],
|
|
edges: [{ index: 0, source: 0, target: 1, data: "BC" }, { index: 1, source: 1, target: 0, data: "CB" }]
|
|
})
|
|
assertSnapshot(Graph.neighborhood(1, { radius: Infinity })(graph), {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "B" }, { index: 1, data: "C" }, { index: 2, data: "D" }],
|
|
edges: [
|
|
{ index: 0, source: 0, target: 1, data: "BC" },
|
|
{ index: 1, source: 1, target: 0, data: "CB" },
|
|
{ index: 2, source: 1, target: 2, data: "CD" }
|
|
]
|
|
})
|
|
for (const radius of [NaN, -1, 0.5]) {
|
|
assertGraphError(
|
|
() => Graph.neighborhood(graph, 1, { radius }),
|
|
"Traversal radius must be a non-negative integer or Infinity"
|
|
)
|
|
}
|
|
})
|
|
|
|
it("can ignore edge direction when selecting a neighborhood", () => {
|
|
const graph = directed(["A", "B", "C"], [[0, 1, "AB"], [0, 2, "AC"]])
|
|
assertSnapshot(Graph.neighborhood(graph, 1, { radius: 2, direction: "undirected" }), {
|
|
type: "directed",
|
|
nodes: [{ index: 0, data: "B" }, { index: 1, data: "A" }, { index: 2, data: "C" }],
|
|
edges: [{ index: 0, source: 1, target: 0, data: "AB" }, { index: 1, source: 1, target: 2, data: "AC" }]
|
|
})
|
|
})
|
|
|
|
it("preserves sparse indexes in induced subgraphs and rejects missing nodes", () => {
|
|
const graph = Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }],
|
|
edges: [
|
|
{ index: 3, source: 2, target: 5, data: "AB" },
|
|
{ index: 7, source: 5, target: 9, data: "BC" },
|
|
{ index: 11, source: 5, target: 5, data: "loop" }
|
|
]
|
|
})
|
|
assertSnapshot(Graph.inducedSubgraph([9, 5, 5])(graph), {
|
|
type: "directed",
|
|
nodes: [{ index: 5, data: "B" }, { index: 9, data: "C" }],
|
|
edges: [{ index: 7, source: 5, target: 9, data: "BC" }, { index: 11, source: 5, target: 5, data: "loop" }]
|
|
})
|
|
assertGraphError(() => Graph.inducedSubgraph(graph, [2, 4]), "Node 4 does not exist")
|
|
})
|
|
|
|
it("preserves graph kind for empty induced subgraphs", () => {
|
|
assertSnapshot(Graph.inducedSubgraph(Graph.undirected<string, never>(), []), {
|
|
type: "undirected",
|
|
nodes: [],
|
|
edges: []
|
|
})
|
|
})
|
|
|
|
it("keeps equal nodes and their edges disjoint in sums", () => {
|
|
const expected = {
|
|
type: "directed",
|
|
nodes: [
|
|
{ index: 0, data: "A" },
|
|
{ index: 1, data: "B" },
|
|
{ index: 2, data: "A" },
|
|
{ index: 3, data: "B" }
|
|
],
|
|
edges: [
|
|
{ index: 0, source: 0, target: 1, data: "left" },
|
|
{ index: 1, source: 2, target: 3, data: "right" }
|
|
]
|
|
} as const
|
|
const first = directed(["A", "B"], [[0, 1, "left"]])
|
|
const second = directed(["A", "B"], [[0, 1, "right"]])
|
|
assertSnapshot(Graph.sum(first, second), expected)
|
|
assertSnapshot(Graph.sum(second)(first), expected)
|
|
})
|
|
})
|
|
|
|
describe("queries", () => {
|
|
const graph = directed(["A", "B", "C"], [
|
|
[0, 1, "first"],
|
|
[0, 1, "parallel"],
|
|
[2, 0, "incoming"],
|
|
[0, 0, "loop"],
|
|
[0, 2, "last"]
|
|
])
|
|
|
|
it("reports exact edge order, multiplicity, and directed degrees", () => {
|
|
assert.deepStrictEqual(Graph.incidentEdges(graph, 0), [0, 1, 2, 3, 4])
|
|
assert.deepStrictEqual(Graph.outgoingEdges(graph, 0), [0, 1, 3, 4])
|
|
assert.deepStrictEqual(Graph.incomingEdges(graph, 0), [2, 3])
|
|
assert.deepStrictEqual(Graph.edgesBetween(graph, 0, 1), [0, 1])
|
|
assert.deepStrictEqual(Graph.edgesBetween(graph, 1, 0), [])
|
|
assert.strictEqual(Graph.outDegree(graph, 0), 4)
|
|
assert.strictEqual(Graph.inDegree(graph, 0), 2)
|
|
})
|
|
|
|
it("deduplicates neighbors in first-edge occurrence order", () => {
|
|
assert.deepStrictEqual(Graph.neighbors(graph, 0), [1, 0, 2])
|
|
assert.deepStrictEqual(Graph.successors(0)(graph), [1, 0, 2])
|
|
assert.deepStrictEqual(Graph.predecessors(graph, 0), [2, 0])
|
|
assert.deepStrictEqual(Graph.neighborsDirected(graph, 0, "outgoing"), [1, 0, 2])
|
|
assert.deepStrictEqual(Graph.successors(graph, 0.5), [])
|
|
})
|
|
|
|
it("deduplicates high-degree neighbors in first-edge occurrence order", () => {
|
|
const targets = Array.from({ length: 34 }, (_, i) => i + 1)
|
|
const graph = directed([0, ...targets], [2, 1, ...targets, 1].map((target) => [0, target, null] as const))
|
|
assert.deepStrictEqual(Graph.neighbors(graph, 0), [2, 1, ...targets.slice(2)])
|
|
})
|
|
|
|
it("handles undirected orientation, self-loops, parallel edges, and degree", () => {
|
|
const graph = undirected(["A", "B"], [[1, 0, 1], [0, 1, 2], [0, 0, 3]])
|
|
assert.deepStrictEqual(Graph.neighbors(graph, 0), [1, 0])
|
|
assert.deepStrictEqual(Graph.neighbors(graph, 1), [0])
|
|
assert.deepStrictEqual(Graph.incidentEdges(graph, 0), [0, 1, 2])
|
|
assert.deepStrictEqual(Graph.edgesBetween(graph, 0, 1), [0, 1])
|
|
assert.strictEqual(Graph.degree(graph, 0), 4)
|
|
assert.strictEqual(Graph.hasEdge(graph, 0, 1), true)
|
|
assert.strictEqual(Graph.hasEdge(graph, 1, 0), true)
|
|
})
|
|
|
|
it("preserves edge order when merging directed incidence and scanning undirected adjacency", () => {
|
|
const directedGraph = directed([0, 1, 2], [
|
|
[0, 1, "out-first"],
|
|
[2, 0, "in-first"],
|
|
[0, 0, "loop"],
|
|
[0, 2, "out-last"],
|
|
[1, 0, "in-last"]
|
|
])
|
|
assert.deepStrictEqual(Graph.incidentEdges(directedGraph, 0), [0, 1, 2, 3, 4])
|
|
assert.deepStrictEqual(Graph.edgesBetween(directedGraph, 0, 0), [2])
|
|
|
|
const undirectedGraph = undirected([0, 1, 2], [
|
|
[1, 0, "reverse"],
|
|
[0, 0, "loop"],
|
|
[0, 2, "forward"],
|
|
[2, 0, "reverse-last"]
|
|
])
|
|
assert.deepStrictEqual(Graph.incidentEdges(undirectedGraph, 0), [0, 1, 2, 3])
|
|
assert.deepStrictEqual(Graph.edgesBetween(undirectedGraph, 0, 2), [2, 3])
|
|
assert.deepStrictEqual(Graph.edgesBetween(undirectedGraph, 2, 0), [2, 3])
|
|
})
|
|
|
|
it("reports directed edge membership without assuming symmetry", () => {
|
|
const graph = directed(["A", "B", "C"], [[0, 1, 1]])
|
|
assert.strictEqual(Graph.hasEdge(graph, 0, 1), true)
|
|
assert.strictEqual(Graph.hasEdge(graph, 1, 0), false)
|
|
assert.strictEqual(Graph.hasEdge(graph, 0, 2), false)
|
|
assert.strictEqual(Graph.hasEdge(graph, 99, 0), false)
|
|
})
|
|
|
|
it("passes stored endpoints to edge finder predicates", () => {
|
|
const graph = undirected(["A", "B", "C"], [[2, 0, "match"], [0, 1, "match"]])
|
|
const calls: Array<readonly [string, number, number]> = []
|
|
assert.deepStrictEqual(
|
|
Graph.findEdge(graph, (data, source, target) => {
|
|
calls.push([data, source, target])
|
|
return source === 0
|
|
}),
|
|
Option.some(1)
|
|
)
|
|
assert.deepStrictEqual(calls, [["match", 2, 0], ["match", 0, 1]])
|
|
assert.deepStrictEqual(Graph.findEdges(graph, (_, source, target) => source > target), [0])
|
|
})
|
|
|
|
it("finds undefined edge payloads", () => {
|
|
const graph = directed<string, number | undefined>(["A", "B", "C"], [
|
|
[0, 1, undefined],
|
|
[1, 2, 42],
|
|
[2, 0, undefined]
|
|
])
|
|
assert.deepStrictEqual(Graph.findEdge(graph, (edge) => edge === undefined), Option.some(0))
|
|
assert.deepStrictEqual(Graph.findEdges(graph, (edge) => edge === undefined), [0, 2])
|
|
})
|
|
|
|
it("reports kind and missing-node errors consistently", () => {
|
|
const one = directed<string, number>(["A"], [])
|
|
const undirectedOne = undirected<string, number>(["A"], [])
|
|
assertGraphError(
|
|
() => Graph.degree(one as unknown as Graph.UndirectedGraph<string, number>, 0),
|
|
"Cannot get degree of directed graph"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.outgoingEdges(undirectedOne as unknown as Graph.DirectedGraph<string, number>, 0),
|
|
"Cannot get outgoing edges of undirected graph"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.incomingEdges(undirectedOne as unknown as Graph.DirectedGraph<string, number>, 0),
|
|
"Cannot get incoming edges of undirected graph"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.successors(undirectedOne as unknown as Graph.DirectedGraph<string, number>, 0),
|
|
"Cannot get successors of undirected graph"
|
|
)
|
|
assertGraphError(() => Graph.incidentEdges(one, 1), "Node 1 does not exist")
|
|
assertGraphError(() => Graph.edgesBetween(one, 0, 1), "Node 1 does not exist")
|
|
})
|
|
})
|
|
|
|
describe("serialization", () => {
|
|
it("serializes representative directed and undirected GraphViz graphs exactly", () => {
|
|
const directedGraph = directed(["A", "B"], [[0, 1, 1]])
|
|
const directedExpected = [
|
|
"digraph \"G\" {",
|
|
" \"0\" [label=\"A\"];",
|
|
" \"1\" [label=\"B\"];",
|
|
" \"0\" -> \"1\" [label=\"1\"];",
|
|
"}"
|
|
].join("\n")
|
|
assert.strictEqual(Graph.toGraphViz(directedGraph), directedExpected)
|
|
assert.strictEqual(Graph.toGraphViz()(directedGraph), directedExpected)
|
|
assert.strictEqual(
|
|
Graph.toGraphViz(undirected(["A", "B"], [[1, 0, "edge"]])),
|
|
[
|
|
"graph \"G\" {",
|
|
" \"0\" [label=\"A\"];",
|
|
" \"1\" [label=\"B\"];",
|
|
" \"1\" -- \"0\" [label=\"edge\"];",
|
|
"}"
|
|
].join("\n")
|
|
)
|
|
})
|
|
|
|
it("escapes GraphViz graph names and labels exactly", () => {
|
|
const graph = directed([{ label: "C:\\new\n\"line\"" }, { label: "end" }], [[
|
|
0,
|
|
1,
|
|
{ label: "edge\\path\n\"quoted\"" }
|
|
]])
|
|
assert.strictEqual(
|
|
Graph.toGraphViz(graph, {
|
|
graphName: "My \"Graph\"",
|
|
nodeLabel: (node) => `node:${node.label}`,
|
|
edgeLabel: (edge) => edge.label
|
|
}),
|
|
[
|
|
"digraph \"My \\\"Graph\\\"\" {",
|
|
" \"0\" [label=\"node:C:\\\\new\\n\\\"line\\\"\"];",
|
|
" \"1\" [label=\"node:end\"];",
|
|
" \"0\" -> \"1\" [label=\"edge\\\\path\\n\\\"quoted\\\"\"];",
|
|
"}"
|
|
].join("\n")
|
|
)
|
|
})
|
|
|
|
it("serializes representative directed and undirected Mermaid graphs exactly", () => {
|
|
const directedGraph = directed(["A", "B"], [[0, 1, "edge"]])
|
|
const directedExpected = [
|
|
"flowchart TD",
|
|
" 0[\"A\"]",
|
|
" 1[\"B\"]",
|
|
" 0 -->|\"edge\"| 1"
|
|
].join("\n")
|
|
assert.strictEqual(Graph.toMermaid(directedGraph), directedExpected)
|
|
assert.strictEqual(Graph.toMermaid()(directedGraph), directedExpected)
|
|
assert.strictEqual(
|
|
Graph.toMermaid(undirected(["A", "B"], [[1, 0, ""]])),
|
|
[
|
|
"graph TD",
|
|
" 0[\"A\"]",
|
|
" 1[\"B\"]",
|
|
" 1 --- 0"
|
|
].join("\n")
|
|
)
|
|
})
|
|
|
|
it("escapes Mermaid labels and applies direction, type, and custom labels", () => {
|
|
const graph = directed([{ id: "#\"<>&[]{}()|\\" }, { id: "B\r\n2\r3\n4" }], [[0, 1, { weight: 2 }]])
|
|
const serialized = Graph.toMermaid(graph, {
|
|
direction: "LR",
|
|
diagramType: "graph",
|
|
nodeLabel: (node) => node.id,
|
|
edgeLabel: (edge) => `w(${edge.weight})`
|
|
})
|
|
assert.strictEqual(
|
|
serialized,
|
|
[
|
|
"graph LR",
|
|
" 0[\"#35;#quot;#lt;#gt;#amp;#91;#93;#123;#125;#40;#41;#124;#92;\"]",
|
|
" 1[\"B<br/>2<br/>3<br/>4\"]",
|
|
" 0 ---|\"w#40;2#41;\"| 1"
|
|
].join("\n")
|
|
)
|
|
assert.strictEqual(serialized.includes("\r"), false)
|
|
})
|
|
|
|
it("supports every Mermaid node shape", () => {
|
|
const expected: ReadonlyArray<readonly [Graph.MermaidNodeShape, string]> = [
|
|
["rectangle", "0[\"A\"]"],
|
|
["rounded", "0(\"A\")"],
|
|
["circle", "0((\"A\"))"],
|
|
["diamond", "0{\"A\"}"],
|
|
["hexagon", "0{{\"A\"}}"],
|
|
["stadium", "0([\"A\"])"],
|
|
["subroutine", "0[[\"A\"]]"],
|
|
["cylindrical", "0[(\"A\")]"]
|
|
]
|
|
for (const [shape, node] of expected) {
|
|
assert.strictEqual(
|
|
Graph.toMermaid(directed<string, never>(["A"], []), { nodeShape: () => shape }),
|
|
`flowchart TD\n ${node}`
|
|
)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("cycles and connectivity", () => {
|
|
it("returns exact sparse cycle witnesses including self-loops and parallel edges", () => {
|
|
const directedCycle = Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }],
|
|
edges: [
|
|
{ index: 3, source: 2, target: 5, data: 1 },
|
|
{ index: 7, source: 5, target: 9, data: 1 },
|
|
{ index: 11, source: 9, target: 2, data: 1 }
|
|
]
|
|
})
|
|
assert.deepStrictEqual(Graph.findCycle(directedCycle), Option.some({ path: [2, 5, 9, 2], edges: [3, 7, 11] }))
|
|
assert.deepStrictEqual(Graph.findCycle(undirected(["A"], [[0, 0, 1]])), Option.some({ path: [0, 0], edges: [0] }))
|
|
assert.deepStrictEqual(
|
|
Graph.findCycle(undirected(["A", "B"], [[0, 1, 1], [1, 0, 2]])),
|
|
Option.some({
|
|
path: [0, 1, 0],
|
|
edges: [0, 1]
|
|
})
|
|
)
|
|
})
|
|
|
|
it("returns None for acyclic directed and reversed-storage undirected graphs", () => {
|
|
assert.deepStrictEqual(Graph.findCycle(directed([0, 1, 2], [[0, 1, 1], [1, 2, 1]])), Option.none())
|
|
assert.deepStrictEqual(Graph.findCycle(undirected([0, 1, 2], [[0, 1, 1], [2, 1, 1]])), Option.none())
|
|
})
|
|
|
|
it("invalidates acyclic results after adding and removing a cycle edge", () => {
|
|
const mutable = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 1]]))
|
|
assert.strictEqual(Graph.isAcyclic(mutable), true)
|
|
const cycle = Graph.addEdge(mutable, 2, 0, 1)
|
|
assert.strictEqual(Graph.isAcyclic(mutable), false)
|
|
Graph.removeEdge(mutable, cycle)
|
|
assert.strictEqual(Graph.isAcyclic(mutable), true)
|
|
})
|
|
|
|
it("handles undirected reversed orientation, self-loops, and parallel cycles", () => {
|
|
assert.strictEqual(Graph.isAcyclic(undirected(["A", "B", "C"], [[0, 1, 1], [2, 1, 1]])), true)
|
|
assert.strictEqual(Graph.isAcyclic(undirected(["A"], [[0, 0, 1]])), false)
|
|
assert.strictEqual(Graph.isAcyclic(undirected(["A", "B"], [[0, 1, 1], [0, 1, 2]])), false)
|
|
})
|
|
|
|
it("returns complete connected, weak, and strong component partitions", () => {
|
|
const connected = undirected(["A", "B", "C", "D", "E"], [[0, 1, 1], [2, 3, 1]])
|
|
const directedGraph = directed(["A", "B", "C", "D", "E"], [[0, 1, 1], [1, 0, 1], [2, 1, 1], [3, 4, 1]])
|
|
|
|
assertComponents(Graph.connectedComponents(connected), [[0, 1], [2, 3], [4]])
|
|
assertComponents(Graph.weaklyConnectedComponents(directedGraph), [[0, 1, 2], [3, 4]])
|
|
assertComponents(Graph.stronglyConnectedComponents(directedGraph), [[0, 1], [2], [3], [4]])
|
|
assertComponents(Graph.connectedComponents(Graph.beginMutation(connected)), [[0, 1], [2, 3], [4]])
|
|
assertComponents(Graph.stronglyConnectedComponents(Graph.beginMutation(directedGraph)), [[0, 1], [2], [3], [4]])
|
|
})
|
|
|
|
it("computes reachability in outgoing, incoming, and undirected modes", () => {
|
|
const graph = directed(["A", "B", "C", "D"], [[0, 1, 1], [1, 2, 1], [3, 1, 1]])
|
|
assert.deepStrictEqual(Array.from(Graph.unweightedDistances(graph, 0)), [[0, 0], [1, 1], [2, 2]])
|
|
assert.deepStrictEqual(Array.from(Graph.unweightedDistances(graph, 2, { direction: "incoming" })), [
|
|
[0, 2],
|
|
[1, 1],
|
|
[2, 0],
|
|
[3, 2]
|
|
])
|
|
assert.deepStrictEqual(Array.from(Graph.unweightedDistances(graph, 0, { direction: "undirected" })), [
|
|
[0, 0],
|
|
[1, 1],
|
|
[2, 2],
|
|
[3, 2]
|
|
])
|
|
assert.strictEqual(Graph.hasPath(graph, 0, 2), true)
|
|
assert.strictEqual(Graph.hasPath(graph, 2, 0), false)
|
|
assert.strictEqual(Graph.hasPath(2, 0, { direction: "incoming" })(graph), true)
|
|
assert.strictEqual(Graph.hasPath(graph, 2, 3), false)
|
|
assert.strictEqual(Graph.hasPath(graph, 2, 3, { direction: "undirected" }), true)
|
|
})
|
|
|
|
it("updates connectivity results after mutable changes", () => {
|
|
const mutable = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1]]))
|
|
assertComponents(Graph.weaklyConnectedComponents(mutable), [[0, 1], [2]])
|
|
assert.strictEqual(Graph.hasPath(mutable, 0, 2), false)
|
|
Graph.addEdge(mutable, 1, 2, 1)
|
|
assertComponents(Graph.weaklyConnectedComponents(mutable), [[0, 1, 2]])
|
|
assert.strictEqual(Graph.hasPath(mutable, 0, 2), true)
|
|
})
|
|
|
|
it("checks connected, weak, strong, and tree predicates including empty graphs", () => {
|
|
const tree = undirected(["A", "B", "C"], [[0, 1, 1], [1, 2, 1]])
|
|
const disconnected = undirected<string, number>(["A", "B"], [])
|
|
const weak = directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 1]])
|
|
const weaklyDisconnected = directed<string, number>(["A", "B"], [])
|
|
const strong = Graph.mutate(weak, (mutable) => {
|
|
Graph.addEdge(mutable, 2, 0, 1)
|
|
})
|
|
assert.strictEqual(Graph.isConnected(tree), true)
|
|
assert.strictEqual(Graph.isConnected(disconnected), false)
|
|
assert.strictEqual(Graph.isTree(tree), true)
|
|
assert.strictEqual(Graph.isWeaklyConnected(weak), true)
|
|
assert.strictEqual(Graph.isWeaklyConnected(weaklyDisconnected), false)
|
|
assert.strictEqual(Graph.isStronglyConnected(weak), false)
|
|
assert.strictEqual(Graph.isStronglyConnected(strong), true)
|
|
assert.strictEqual(Graph.isConnected(Graph.undirected()), true)
|
|
assert.strictEqual(Graph.isTree(Graph.undirected()), false)
|
|
})
|
|
|
|
it("rejects runtime connectivity kind mismatches and missing endpoints", () => {
|
|
const directedGraph = Graph.directed() as unknown as Graph.Graph<never, never, Graph.Kind>
|
|
const undirectedGraph = Graph.undirected() as unknown as Graph.Graph<never, never, Graph.Kind>
|
|
assertGraphError(
|
|
() => Graph.connectedComponents(directedGraph as Graph.UndirectedGraph<never, never>),
|
|
"Cannot find connected components of directed graph"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.weaklyConnectedComponents(undirectedGraph as Graph.DirectedGraph<never, never>),
|
|
"Cannot find weakly connected components of undirected graph"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.stronglyConnectedComponents(undirectedGraph as Graph.DirectedGraph<never, never>),
|
|
"Cannot find strongly connected components of undirected graph"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.isTree(directedGraph as Graph.UndirectedGraph<never, never>),
|
|
"Cannot determine tree status of directed graph"
|
|
)
|
|
assertGraphError(() => Graph.hasPath(Graph.directed(), 0, 1), "Node 0 does not exist")
|
|
})
|
|
})
|
|
|
|
describe("bipartite graphs", () => {
|
|
it("recognizes even, odd, disconnected, and self-loop cases", () => {
|
|
const even = undirected([0, 1, 2, 3], [[0, 1, 1], [1, 2, 1], [2, 3, 1], [3, 0, 1]])
|
|
assert.strictEqual(Graph.isBipartite(even), true)
|
|
assert.strictEqual(Graph.isBipartite(Graph.beginMutation(even)), true)
|
|
assert.strictEqual(Graph.isBipartite(undirected([0, 1, 2], [[0, 1, 1], [1, 2, 1], [2, 0, 1]])), false)
|
|
assert.strictEqual(Graph.isBipartite(undirected([0, 1, 2, 3], [[0, 1, 1], [2, 3, 1]])), true)
|
|
assert.strictEqual(Graph.isBipartite(undirected([0], [[0, 0, 1]])), false)
|
|
})
|
|
|
|
it("reads fresh mutable structure after bipartite mutations", () => {
|
|
const mutable = Graph.beginMutation(undirected([0, 1, 2], [[0, 1, 1], [1, 2, 1]]))
|
|
assert.strictEqual(Graph.isBipartite(mutable), true)
|
|
Graph.addEdge(mutable, 2, 0, 1)
|
|
assert.strictEqual(Graph.isBipartite(mutable), false)
|
|
Graph.removeEdge(mutable, 2)
|
|
Graph.addEdge(mutable, 0, 1, 1)
|
|
assert.strictEqual(Graph.isBipartite(mutable), true)
|
|
Graph.addEdge(mutable, 2, 2, 1)
|
|
assert.strictEqual(Graph.isBipartite(mutable), false)
|
|
})
|
|
|
|
it("returns deterministic maximum matches and the first parallel edge", () => {
|
|
const graph = undirected([0, 1, 2, 3], [[0, 2, "first"], [2, 0, "parallel"], [0, 3, "edge"], [1, 2, "edge"], [
|
|
1,
|
|
3,
|
|
"edge"
|
|
]])
|
|
assert.deepStrictEqual(Graph.maximumBipartiteMatching(graph), [
|
|
{ left: 0, right: 2, edge: 0 },
|
|
{ left: 1, right: 3, edge: 4 }
|
|
])
|
|
assert.deepStrictEqual(Graph.maximumBipartiteMatching(Graph.beginMutation(graph)), [
|
|
{ left: 0, right: 2, edge: 0 },
|
|
{ left: 1, right: 3, edge: 4 }
|
|
])
|
|
})
|
|
|
|
it("matches a brute-force oracle for every three-by-three bipartite graph", () => {
|
|
const oracle = (adjacency: ReadonlyArray<ReadonlyArray<number>>, left = 0, used = 0): number => {
|
|
if (left === adjacency.length) return 0
|
|
let best = oracle(adjacency, left + 1, used)
|
|
for (const right of adjacency[left]) {
|
|
if ((used & (1 << right)) === 0) best = Math.max(best, 1 + oracle(adjacency, left + 1, used | (1 << right)))
|
|
}
|
|
return best
|
|
}
|
|
for (let mask = 0; mask < 1 << 9; mask++) {
|
|
const adjacency: Array<Array<number>> = [[], [], []]
|
|
const graph = Graph.undirected<void, void>((mutable) => {
|
|
for (let i = 0; i < 6; i++) Graph.addNode(mutable, undefined)
|
|
for (let left = 0; left < 3; left++) {
|
|
for (let right = 0; right < 3; right++) {
|
|
if ((mask & (1 << (left * 3 + right))) !== 0) {
|
|
adjacency[left].push(right)
|
|
Graph.addEdge(mutable, left, right + 3, undefined)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
const matching = Graph.maximumBipartiteMatching(graph)
|
|
assert.strictEqual(matching.length, oracle(adjacency))
|
|
assert.strictEqual(new Set(matching.map((match) => match.left)).size, matching.length)
|
|
assert.strictEqual(new Set(matching.map((match) => match.right)).size, matching.length)
|
|
for (const match of matching) {
|
|
assert.ok(match.left >= 0 && match.left < 3)
|
|
assert.ok(match.right >= 3 && match.right < 6)
|
|
const edge = Option.getOrThrow(Graph.getEdge(graph, match.edge))
|
|
assert.ok(
|
|
(edge.source === match.left && edge.target === match.right) ||
|
|
(edge.source === match.right && edge.target === match.left)
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
it("rejects directed and non-bipartite graphs", () => {
|
|
assertGraphError(
|
|
() => Graph.isBipartite(Graph.directed() as unknown as Graph.UndirectedGraph<never, never>),
|
|
"Cannot determine bipartite status of directed graph"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.maximumBipartiteMatching(Graph.directed() as unknown as Graph.UndirectedGraph<never, never>),
|
|
"Cannot find bipartite matching of directed graph"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.maximumBipartiteMatching(undirected([0, 1, 2], [[0, 1, 1], [1, 2, 1], [2, 0, 1]])),
|
|
"Cannot find bipartite matching of non-bipartite graph"
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("low-link connectivity", () => {
|
|
const analyze = <N, E>(graph: Graph.UndirectedGraph<N, E> | Graph.MutableUndirectedGraph<N, E>) => ({
|
|
bridges: Graph.bridges(graph),
|
|
articulationPoints: Graph.articulationPoints(graph),
|
|
biconnectedComponents: Graph.biconnectedComponents(graph)
|
|
})
|
|
|
|
it("handles paths, cycles, disconnected components, parallel edges, loops, and sparse indexes", () => {
|
|
const graph = Graph.fromSnapshot({
|
|
type: "undirected",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }, { index: 12, data: "D" }],
|
|
edges: [
|
|
{ index: 3, source: 5, target: 2, data: "first" },
|
|
{ index: 7, source: 2, target: 5, data: "parallel" },
|
|
{ index: 11, source: 5, target: 9, data: "bridge" },
|
|
{ index: 13, source: 9, target: 9, data: "loop" }
|
|
]
|
|
})
|
|
const expected = {
|
|
bridges: [11],
|
|
articulationPoints: [5],
|
|
biconnectedComponents: [[2, 5], [5, 9], [9]]
|
|
}
|
|
assert.deepStrictEqual(analyze(graph), expected)
|
|
assert.deepStrictEqual(analyze(Graph.beginMutation(graph)), expected)
|
|
})
|
|
|
|
it("groups cycles sharing an articulation point across disconnected components", () => {
|
|
const graph = undirected<void, void>(new Array(8).fill(undefined), [
|
|
[0, 1, undefined],
|
|
[1, 2, undefined],
|
|
[2, 0, undefined],
|
|
[2, 3, undefined],
|
|
[3, 4, undefined],
|
|
[4, 2, undefined],
|
|
[5, 6, undefined]
|
|
])
|
|
assert.deepStrictEqual(analyze(graph), {
|
|
bridges: [6],
|
|
articulationPoints: [2],
|
|
biconnectedComponents: [[0, 1, 2], [2, 3, 4], [5, 6]]
|
|
})
|
|
})
|
|
|
|
it("returns empty results for empty and isolated graphs", () => {
|
|
assert.deepStrictEqual(analyze(Graph.undirected()), {
|
|
bridges: [],
|
|
articulationPoints: [],
|
|
biconnectedComponents: []
|
|
})
|
|
assert.deepStrictEqual(analyze(undirected<void, never>([undefined], [])), {
|
|
bridges: [],
|
|
articulationPoints: [],
|
|
biconnectedComponents: []
|
|
})
|
|
})
|
|
|
|
it("rejects directed graphs at runtime", () => {
|
|
const graph = Graph.directed() as unknown as Graph.UndirectedGraph<never, never>
|
|
for (const operation of [Graph.bridges, Graph.articulationPoints, Graph.biconnectedComponents]) {
|
|
assertGraphError(() => operation(graph), "Cannot analyze undirected connectivity of directed graph")
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("flow", () => {
|
|
it("returns fixed flow and cut results in data-first and data-last forms", () => {
|
|
const graph = directed(["source", "target"], [[0, 1, 3]])
|
|
const config = { source: 0, target: 1, capacity: (edge: number) => edge }
|
|
const flow = { value: 3, flows: new Map([[0, 3]]), cut: [0] }
|
|
const cut = { value: 3, edges: [0], source: [0], target: [1] }
|
|
assert.deepStrictEqual(Graph.maximumFlow(graph, config), flow)
|
|
assert.deepStrictEqual(Graph.maximumFlow(config)(graph), flow)
|
|
assert.deepStrictEqual(Graph.minimumCut(graph, config), cut)
|
|
assert.deepStrictEqual(Graph.minimumCut(config)(Graph.beginMutation(graph)), cut)
|
|
})
|
|
|
|
it("enforces capacities, conservation, and max-flow/min-cut equality", () => {
|
|
const capacities = [16, 13, 10, 4, 12, 9, 14, 7, 20, 4]
|
|
const endpoints = [[0, 1], [0, 2], [1, 2], [2, 1], [1, 3], [3, 2], [2, 4], [4, 3], [3, 5], [4, 5]] as const
|
|
const graph = directed<void, number>(
|
|
new Array(6).fill(undefined),
|
|
endpoints.map(([source, target], index) => [source, target, capacities[index]])
|
|
)
|
|
const config = { source: 0, target: 5, capacity: (edge: number) => edge }
|
|
for (const candidate of [graph, Graph.beginMutation(graph)]) {
|
|
const flow = Graph.maximumFlow(candidate, config)
|
|
const cut = Graph.minimumCut(candidate, config)
|
|
const balance = new Float64Array(6)
|
|
for (let edge = 0; edge < endpoints.length; edge++) {
|
|
const value = flow.flows.get(edge)!
|
|
assert.ok(value >= 0 && value <= capacities[edge])
|
|
balance[endpoints[edge][0]] -= value
|
|
balance[endpoints[edge][1]] += value
|
|
}
|
|
assert.strictEqual(flow.value, 23)
|
|
assert.strictEqual(cut.value, 23)
|
|
assert.strictEqual(cut.edges.reduce((total, edge) => total + capacities[edge], 0), 23)
|
|
assert.deepStrictEqual(Array.from(balance), [-23, 0, 0, 0, 0, 23])
|
|
assert.deepStrictEqual([...cut.source, ...cut.target].sort((a, b) => a - b), [0, 1, 2, 3, 4, 5])
|
|
}
|
|
})
|
|
|
|
it("uses reverse residual edges to reroute flow", () => {
|
|
const graph = directed<void, number>(new Array(6).fill(undefined), [
|
|
[0, 1, 1],
|
|
[0, 2, 1],
|
|
[1, 3, 1],
|
|
[2, 3, 1],
|
|
[3, 5, 1],
|
|
[1, 4, 1],
|
|
[4, 5, 1]
|
|
])
|
|
assert.strictEqual(Graph.maximumFlow(graph, { source: 0, target: 5, capacity: (edge) => edge }).value, 2)
|
|
})
|
|
|
|
it("handles parallel edges, self-loops, fractions, and disconnected targets", () => {
|
|
const graph = directed<void, number>(new Array(4).fill(undefined), [
|
|
[0, 0, 100],
|
|
[0, 1, 0],
|
|
[0, 2, 0.75],
|
|
[2, 1, 1],
|
|
[0, 2, 0.25]
|
|
])
|
|
assert.deepStrictEqual(Graph.maximumFlow(graph, { source: 0, target: 1, capacity: (edge) => edge }), {
|
|
value: 1,
|
|
flows: new Map([[0, 0], [1, 0], [2, 0.75], [3, 1], [4, 0.25]]),
|
|
cut: [1, 2, 4]
|
|
})
|
|
assert.deepStrictEqual(Graph.maximumFlow(graph, { source: 0, target: 3, capacity: (edge) => edge }), {
|
|
value: 0,
|
|
flows: new Map([[0, 0], [1, 0], [2, 0], [3, 0], [4, 0]]),
|
|
cut: []
|
|
})
|
|
})
|
|
|
|
it("rejects invalid graphs, endpoints, capacities, and finite-range overflow", () => {
|
|
const graph = directed<void, number>([undefined, undefined], [])
|
|
assertGraphError(
|
|
() => Graph.maximumFlow(graph, { source: 0, target: 2, capacity: (edge) => edge }),
|
|
"Node 2 does not exist"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.maximumFlow(graph, { source: 0, target: 0, capacity: (edge) => edge }),
|
|
"Flow source and target must be different nodes"
|
|
)
|
|
assertGraphError(
|
|
() =>
|
|
Graph.maximumFlow(Graph.undirected() as unknown as Graph.DirectedGraph<never, number>, {
|
|
source: 0,
|
|
target: 1,
|
|
capacity: (edge) => edge
|
|
}),
|
|
"Cannot compute flow of undirected graph"
|
|
)
|
|
for (const capacity of [-1, NaN, Infinity, -Infinity]) {
|
|
const invalid = directed<void, number>([undefined, undefined], [[0, 0, capacity]])
|
|
assertGraphError(
|
|
() => Graph.maximumFlow(invalid, { source: 0, target: 1, capacity: (edge) => edge }),
|
|
"Edge 0 capacity must be a finite non-negative number"
|
|
)
|
|
}
|
|
const overflow = directed<void, number>([undefined, undefined], [[0, 1, Number.MAX_VALUE], [
|
|
0,
|
|
1,
|
|
Number.MAX_VALUE
|
|
]])
|
|
assertGraphError(
|
|
() => Graph.maximumFlow(overflow, { source: 0, target: 1, capacity: (edge) => edge }),
|
|
"Maximum flow exceeds the finite number range"
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("spanning forests and reductions", () => {
|
|
it("preserves sparse indexes, isolated nodes, and fixed mutable parity in minimum spanning forests", () => {
|
|
const graph = Graph.fromSnapshot({
|
|
type: "undirected",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }, {
|
|
index: 20,
|
|
data: "isolated"
|
|
}],
|
|
edges: [
|
|
{ index: 3, source: 2, target: 5, data: 4 },
|
|
{ index: 7, source: 2, target: 5, data: 1 },
|
|
{ index: 11, source: 5, target: 9, data: -2 },
|
|
{ index: 13, source: 2, target: 9, data: 2 },
|
|
{ index: 17, source: 9, target: 20, data: Infinity }
|
|
]
|
|
})
|
|
const expected = {
|
|
type: "undirected",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }, {
|
|
index: 20,
|
|
data: "isolated"
|
|
}],
|
|
edges: [{ index: 7, source: 2, target: 5, data: 1 }, { index: 11, source: 5, target: 9, data: -2 }]
|
|
} as const
|
|
assertSnapshot(Graph.minimumSpanningForest(graph, (edge) => edge), expected)
|
|
assertSnapshot(Graph.minimumSpanningForest(Graph.beginMutation(graph), (edge) => edge), expected)
|
|
})
|
|
|
|
it("breaks equal spanning-forest weights by first edge order", () => {
|
|
const graph = undirected([0, 1, 2], [[0, 1, 1], [1, 2, 1], [0, 2, 1]])
|
|
assert.deepStrictEqual(
|
|
Graph.toSnapshot(Graph.minimumSpanningForest(graph, (edge) => edge)).edges.map((edge) => edge.index),
|
|
[0, 1]
|
|
)
|
|
})
|
|
|
|
it("preserves sparse reachability and first parallel edges in transitive reductions", () => {
|
|
const graph = Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }],
|
|
edges: [
|
|
{ index: 3, source: 2, target: 5, data: "first" },
|
|
{ index: 4, source: 2, target: 5, data: "parallel" },
|
|
{ index: 7, source: 5, target: 9, data: "next" },
|
|
{ index: 11, source: 2, target: 9, data: "redundant" }
|
|
]
|
|
})
|
|
const expected = {
|
|
type: "directed",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }],
|
|
edges: [{ index: 3, source: 2, target: 5, data: "first" }, { index: 7, source: 5, target: 9, data: "next" }]
|
|
} as const
|
|
assertSnapshot(Graph.transitiveReduction(graph), expected)
|
|
assertSnapshot(Graph.transitiveReduction(Graph.beginMutation(graph)), expected)
|
|
})
|
|
|
|
it("keeps both branches of a diamond", () => {
|
|
const graph = directed([0, 1, 2, 3], [[0, 1, 1], [0, 2, 1], [1, 3, 1], [2, 3, 1]])
|
|
assert.deepStrictEqual(Array.from(Graph.indices(Graph.edges(Graph.transitiveReduction(graph)))), [0, 1, 2, 3])
|
|
})
|
|
|
|
it("rejects invalid kinds, weights, and cyclic reductions", () => {
|
|
assertGraphError(
|
|
() => Graph.minimumSpanningForest(Graph.directed() as unknown as Graph.UndirectedGraph<never, number>, () => 1),
|
|
"Cannot find minimum spanning forest of directed graph"
|
|
)
|
|
for (const weight of [NaN, -Infinity]) {
|
|
assertGraphError(
|
|
() => Graph.minimumSpanningForest(undirected([0, 1], [[0, 1, weight]]), (edge) => edge),
|
|
"Minimum spanning forest does not support NaN or -Infinity edge weights"
|
|
)
|
|
}
|
|
assertGraphError(
|
|
() => Graph.transitiveReduction(Graph.undirected() as unknown as Graph.DirectedGraph<never, never>),
|
|
"Cannot transitively reduce undirected graph"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.transitiveReduction(directed([0, 1], [[0, 1, 1], [1, 0, 1]])),
|
|
"Cannot transitively reduce cyclic graph"
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("pathfinding", () => {
|
|
const graph = directed(["source", "first", "second", "target"], [
|
|
[0, 1, 1],
|
|
[0, 2, 1],
|
|
[2, 3, 1],
|
|
[1, 3, 1]
|
|
])
|
|
const expected = { path: [0, 1, 3], edges: [0, 3], distance: 2, costs: [1, 1] }
|
|
|
|
it("returns complete Dijkstra and A* paths with deterministic ties and fixed mutable parity", () => {
|
|
const dijkstra = { source: 0, target: 3, cost: (edge: number) => edge }
|
|
const astar = { ...dijkstra, heuristic: () => 0 }
|
|
const bellmanFord = { path: [0, 2, 3], edges: [1, 2], distance: 2, costs: [1, 1] }
|
|
assertPath(Graph.dijkstra(graph, dijkstra), expected)
|
|
assertPath(Graph.dijkstra(Graph.beginMutation(graph), dijkstra), expected)
|
|
assertPath(Graph.dijkstra(dijkstra)(graph), expected)
|
|
assertPath(Graph.astar(graph, astar), expected)
|
|
assertPath(Graph.astar(Graph.beginMutation(graph), astar), expected)
|
|
assertPath(Graph.astar(astar)(graph), expected)
|
|
assertPath(Graph.bellmanFord(dijkstra)(graph), bellmanFord)
|
|
const all = Graph.floydWarshall((edge: number) => edge)(graph)
|
|
assert.strictEqual(all.distances.get(0)?.get(3), 2)
|
|
assert.deepStrictEqual(all.paths.get(0)?.get(3), expected.path)
|
|
assert.deepStrictEqual(all.edges.get(0)?.get(3), expected.edges)
|
|
assert.deepStrictEqual(all.costs.get(0)?.get(3), expected.costs)
|
|
})
|
|
|
|
it("preserves parallel edges for topological and weighted algorithms", () => {
|
|
const graph = directed([0, 1, 2], [[0, 1, 10], [0, 1, 1], [1, 2, 2]])
|
|
|
|
assertIndices(Graph.topo(graph), [0, 1, 2])
|
|
assertPath(Graph.dijkstra(graph, { source: 0, target: 2, cost: (edge) => edge }), {
|
|
path: [0, 1, 2],
|
|
edges: [1, 2],
|
|
distance: 3,
|
|
costs: [1, 2]
|
|
})
|
|
})
|
|
|
|
it("handles decreased Dijkstra priorities with fresh order and stale entries", () => {
|
|
const reordered = directed(["source", "improved", "shortcut", "direct", "target"], [
|
|
[0, 1, 10],
|
|
[0, 2, 1],
|
|
[0, 3, 2],
|
|
[2, 1, 1],
|
|
[1, 4, 1],
|
|
[3, 4, 1]
|
|
])
|
|
assertPath(Graph.dijkstra(reordered, { source: 0, target: 4, cost: (edge) => edge }), {
|
|
path: [0, 3, 4],
|
|
edges: [2, 5],
|
|
distance: 3,
|
|
costs: [2, 1]
|
|
})
|
|
|
|
const stale = directed(["source", "improved", "shortcut", "middle", "target"], [
|
|
[0, 1, 10],
|
|
[0, 2, 1],
|
|
[2, 1, 1],
|
|
[1, 3, 20],
|
|
[3, 4, 20]
|
|
])
|
|
assertPath(Graph.dijkstra(stale, { source: 0, target: 4, cost: (edge) => edge }), {
|
|
path: [0, 2, 1, 3, 4],
|
|
edges: [1, 2, 3, 4],
|
|
distance: 42,
|
|
costs: [1, 1, 20, 20]
|
|
})
|
|
})
|
|
|
|
it("skips stale A* entries and does not reopen closed nodes", () => {
|
|
const stale = directed(["source", "improved", "shortcut", "middle", "target"], [
|
|
[0, 1, 10],
|
|
[0, 2, 1],
|
|
[2, 1, 1],
|
|
[1, 3, 20],
|
|
[3, 4, 20]
|
|
])
|
|
assertPath(Graph.astar(stale, { source: 0, target: 4, cost: (edge) => edge, heuristic: () => 0 }), {
|
|
path: [0, 2, 1, 3, 4],
|
|
edges: [1, 2, 3, 4],
|
|
distance: 42,
|
|
costs: [1, 1, 20, 20]
|
|
})
|
|
|
|
const closed = directed(["source", "closed", "later", "target"], [
|
|
[0, 1, 10],
|
|
[1, 3, 1],
|
|
[0, 2, 1],
|
|
[2, 1, 1]
|
|
])
|
|
assertPath(
|
|
Graph.astar(closed, {
|
|
source: 0,
|
|
target: 3,
|
|
cost: (edge) => edge,
|
|
heuristic: (node) => node === "closed" ? -100 : 0
|
|
}),
|
|
{ path: [0, 1, 3], edges: [0, 1], distance: 11, costs: [10, 1] }
|
|
)
|
|
})
|
|
|
|
it("preserves sparse parallel edge identity across all shortest-path algorithms", () => {
|
|
const graph = Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 2, data: "source" }, { index: 5, data: "target" }],
|
|
edges: [{ index: 3, source: 2, target: 5, data: 1 }, { index: 1_000_000, source: 2, target: 5, data: 1 }]
|
|
})
|
|
const expected = { path: [2, 5], edges: [3], distance: 1, costs: [1] }
|
|
for (const candidate of [graph, Graph.beginMutation(graph)]) {
|
|
assertPath(Graph.dijkstra(candidate, { source: 2, target: 5, cost: (edge) => edge }), expected)
|
|
assertPath(Graph.astar(candidate, { source: 2, target: 5, cost: (edge) => edge, heuristic: () => 0 }), expected)
|
|
assertPath(Graph.bellmanFord(candidate, { source: 2, target: 5, cost: (edge) => edge }), expected)
|
|
const all = Graph.floydWarshall(candidate, (edge) => edge)
|
|
assert.strictEqual(all.distances.get(2)?.get(5), 1)
|
|
assert.deepStrictEqual(all.paths.get(2)?.get(5), [2, 5])
|
|
assert.deepStrictEqual(all.edges.get(2)?.get(5), [3])
|
|
assert.deepStrictEqual(all.costs.get(2)?.get(5), [1])
|
|
}
|
|
})
|
|
|
|
it("handles unreachable and same-node paths completely", () => {
|
|
const graph = directed<string, number>(["A", "B"], [])
|
|
const expected = { path: [0], edges: [], distance: 0, costs: [] }
|
|
assert.deepStrictEqual(Graph.dijkstra(graph, { source: 0, target: 1, cost: (edge) => edge }), Option.none())
|
|
assertPath(Graph.dijkstra(graph, { source: 0, target: 0, cost: (edge) => edge }), expected)
|
|
assertPath(Graph.astar(graph, { source: 0, target: 0, cost: (edge) => edge, heuristic: () => 0 }), expected)
|
|
assertPath(Graph.bellmanFord(graph, { source: 0, target: 0, cost: (edge) => edge }), expected)
|
|
const all = Graph.floydWarshall(graph, (edge) => edge)
|
|
assert.strictEqual(all.distances.get(0)?.get(1), Infinity)
|
|
assert.strictEqual(all.paths.get(0)?.get(1), null)
|
|
assert.deepStrictEqual(all.edges.get(0)?.get(1), [])
|
|
assert.deepStrictEqual(all.costs.get(0)?.get(1), [])
|
|
assert.strictEqual(all.distances.get(0)?.get(0), 0)
|
|
assert.deepStrictEqual(all.paths.get(0)?.get(0), [0])
|
|
assert.deepStrictEqual(all.edges.get(0)?.get(0), [])
|
|
assert.deepStrictEqual(all.costs.get(0)?.get(0), [])
|
|
})
|
|
|
|
it("uses negative Bellman-Ford edges in shortest paths", () => {
|
|
assertPath(
|
|
Graph.bellmanFord(directed([0, 1, 2], [[0, 1, -1], [1, 2, 3], [0, 2, 5]]), {
|
|
source: 0,
|
|
target: 2,
|
|
cost: (edge) => edge
|
|
}),
|
|
{ path: [0, 1, 2], edges: [0, 1], distance: 2, costs: [-1, 3] }
|
|
)
|
|
})
|
|
|
|
it("ignores negative cycles separated from the Bellman-Ford target by impassable edges", () => {
|
|
const graph = directed([0, 1, 2, 3], [[0, 1, 1], [1, 2, -2], [2, 1, 1], [0, 3, 5], [2, 3, Infinity]])
|
|
const expected = { path: [0, 3], edges: [3], distance: 5, costs: [5] }
|
|
assertPath(Graph.bellmanFord(graph, { source: 0, target: 3, cost: (edge) => edge }), expected)
|
|
assertPath(
|
|
Graph.bellmanFord(Graph.beginMutation(graph), { source: 0, target: 3, cost: (edge) => edge }),
|
|
expected
|
|
)
|
|
|
|
const unreachable = directed([0, 1, 2, 3], [[0, 1, 1], [1, 2, -2], [2, 1, 1], [2, 3, Infinity]])
|
|
assert.deepStrictEqual(
|
|
Graph.bellmanFord(unreachable, { source: 0, target: 3, cost: (edge) => edge }),
|
|
Option.none()
|
|
)
|
|
})
|
|
|
|
it("detects directed and undirected negative self-loops when source equals target", () => {
|
|
for (const graph of [directed([0], [[0, 0, -1]]), undirected([0], [[0, 0, -1]])]) {
|
|
assertGraphError(
|
|
() => Graph.bellmanFord(graph, { source: 0, target: 0, cost: (edge) => edge }),
|
|
"Negative cycle affects path to node 0"
|
|
)
|
|
}
|
|
})
|
|
|
|
it("preserves null edge payloads in Floyd-Warshall multihop paths", () => {
|
|
type Edge = null | { readonly weight: number }
|
|
const graph = directed<string, Edge>(["A", "B", "C"], [
|
|
[0, 1, null],
|
|
[1, 2, null],
|
|
[0, 2, { weight: 10 }]
|
|
])
|
|
const result = Graph.floydWarshall(graph, (edge) => edge === null ? 1 : edge.weight)
|
|
assert.strictEqual(result.distances.get(0)?.get(2), 2)
|
|
assert.deepStrictEqual(result.paths.get(0)?.get(2), [0, 1, 2])
|
|
assert.deepStrictEqual(result.edges.get(0)?.get(2), [0, 1])
|
|
assert.deepStrictEqual(result.costs.get(0)?.get(2), [null, null])
|
|
})
|
|
|
|
it("treats positive Infinity as unreachable in every shortest-path algorithm", () => {
|
|
const graph = directed([0, 1], [[0, 1, Infinity]])
|
|
assert.deepStrictEqual(Graph.dijkstra(graph, { source: 0, target: 1, cost: (edge) => edge }), Option.none())
|
|
assert.deepStrictEqual(
|
|
Graph.astar(graph, { source: 0, target: 1, cost: (edge) => edge, heuristic: () => 0 }),
|
|
Option.none()
|
|
)
|
|
assert.deepStrictEqual(Graph.bellmanFord(graph, { source: 0, target: 1, cost: (edge) => edge }), Option.none())
|
|
const all = Graph.floydWarshall(graph, (edge) => edge)
|
|
assert.strictEqual(all.distances.get(0)?.get(1), Infinity)
|
|
assert.strictEqual(all.paths.get(0)?.get(1), null)
|
|
assert.deepStrictEqual(all.edges.get(0)?.get(1), [])
|
|
assert.deepStrictEqual(all.costs.get(0)?.get(1), [])
|
|
})
|
|
|
|
it("reports relevant Bellman-Ford and Floyd-Warshall negative cycles", () => {
|
|
const cycle = directed([0, 1, 2], [[0, 1, 1], [1, 2, -3], [2, 0, 1]])
|
|
assertGraphError(
|
|
() => Graph.bellmanFord(cycle, { source: 0, target: 2, cost: (edge) => edge }),
|
|
"Negative cycle affects path to node 2"
|
|
)
|
|
assertGraphError(() => Graph.floydWarshall(cycle, (edge) => edge), "Negative cycle detected involving node 0")
|
|
const negativeUndirected = undirected([0, 1], [[0, 1, -1]])
|
|
assertGraphError(
|
|
() => Graph.bellmanFord(negativeUndirected, { source: 0, target: 1, cost: (edge) => edge }),
|
|
"Negative cycle affects path to node 1"
|
|
)
|
|
})
|
|
|
|
it("validates endpoints, edge weights, heuristics, and finite arithmetic", () => {
|
|
assertGraphError(
|
|
() => Graph.dijkstra(Graph.directed(), { source: 0, target: 1, cost: () => 1 }),
|
|
"Node 0 does not exist"
|
|
)
|
|
for (const weight of [-1, NaN, -Infinity]) {
|
|
const invalid = directed([0, 1], [[0, 1, weight]])
|
|
assertGraphError(
|
|
() => Graph.dijkstra(invalid, { source: 0, target: 1, cost: (edge) => edge }),
|
|
"Dijkstra's algorithm requires non-negative edge weights"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.astar(invalid, { source: 0, target: 1, cost: (edge) => edge, heuristic: () => 0 }),
|
|
"A* algorithm requires non-negative edge weights"
|
|
)
|
|
}
|
|
for (const heuristic of [NaN, Infinity, -Infinity]) {
|
|
assertGraphError(() =>
|
|
Graph.astar(directed<string, never>(["A"], []), {
|
|
source: 0,
|
|
target: 0,
|
|
cost: () => 1,
|
|
heuristic: () => heuristic
|
|
}), "A* algorithm requires finite heuristic values")
|
|
assertGraphError(() =>
|
|
Graph.astar(directed(["source", "middle", "target"], [[0, 1, 1], [1, 2, 1]]), {
|
|
source: 0,
|
|
target: 2,
|
|
cost: (edge) => edge,
|
|
heuristic: (node) => node === "middle" ? heuristic : 0
|
|
}), "A* algorithm requires finite heuristic values")
|
|
}
|
|
for (const weight of [NaN, -Infinity]) {
|
|
const invalid = directed([0, 1], [[0, 1, weight]])
|
|
assertGraphError(
|
|
() => Graph.bellmanFord(invalid, { source: 0, target: 1, cost: (edge) => edge }),
|
|
"Bellman-Ford algorithm does not support NaN or -Infinity edge weights"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.floydWarshall(invalid, (edge) => edge),
|
|
"Floyd-Warshall algorithm does not support NaN or -Infinity edge weights"
|
|
)
|
|
}
|
|
const overflow = directed([0, 1, 2], [[0, 1, Number.MAX_VALUE], [1, 2, Number.MAX_VALUE]])
|
|
assertGraphError(
|
|
() => Graph.dijkstra(overflow, { source: 0, target: 2, cost: (edge) => edge }),
|
|
"Dijkstra distance calculation exceeded the finite number range"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.astar(overflow, { source: 0, target: 2, cost: (edge) => edge, heuristic: () => 0 }),
|
|
"A* distance calculation exceeded the finite number range"
|
|
)
|
|
assertGraphError(
|
|
() =>
|
|
Graph.astar(directed([0, 1], [[0, 1, Number.MAX_VALUE]]), {
|
|
source: 0,
|
|
target: 1,
|
|
cost: (edge) => edge,
|
|
heuristic: (node) => node === 1 ? Number.MAX_VALUE : 0
|
|
}),
|
|
"A* priority calculation exceeded the finite number range"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.bellmanFord(overflow, { source: 0, target: 2, cost: (edge) => edge }),
|
|
"Bellman-Ford distance calculation exceeded the finite number range"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.floydWarshall(overflow, (edge) => edge),
|
|
"Floyd-Warshall distance calculation exceeded the finite number range"
|
|
)
|
|
const underflow = directed([0, 1, 2], [[0, 1, -Number.MAX_VALUE], [1, 2, -Number.MAX_VALUE]])
|
|
assertGraphError(
|
|
() => Graph.bellmanFord(underflow, { source: 0, target: 2, cost: (edge) => edge }),
|
|
"Bellman-Ford distance calculation exceeded the finite number range"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.floydWarshall(underflow, (edge) => edge),
|
|
"Floyd-Warshall distance calculation exceeded the finite number range"
|
|
)
|
|
})
|
|
|
|
it("validates negative Dijkstra and A* weights before early returns", () => {
|
|
const early = directed([0, 1, 2], [[0, 1, 1], [0, 2, 2], [2, 1, -5]])
|
|
const same = directed([0], [[0, 0, -1]])
|
|
for (const [graph, source, target] of [[early, 0, 1], [same, 0, 0]] as const) {
|
|
assertGraphError(
|
|
() => Graph.dijkstra(graph, { source, target, cost: (edge) => edge }),
|
|
"Dijkstra's algorithm requires non-negative edge weights"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.astar(graph, { source, target, cost: (edge) => edge, heuristic: () => 0 }),
|
|
"A* algorithm requires non-negative edge weights"
|
|
)
|
|
}
|
|
})
|
|
|
|
it("traverses undirected edges against stored orientation", () => {
|
|
const graph = undirected(["A", "B", "C"], [[0, 1, 1], [2, 1, 1]])
|
|
const expected = { path: [0, 1, 2], edges: [0, 1], distance: 2, costs: [1, 1] }
|
|
assertPath(Graph.dijkstra(graph, { source: 0, target: 2, cost: (edge) => edge }), expected)
|
|
assertPath(Graph.astar(graph, { source: 0, target: 2, cost: (edge) => edge, heuristic: () => 0 }), expected)
|
|
assertPath(Graph.bellmanFord(graph, { source: 0, target: 2, cost: (edge) => edge }), expected)
|
|
const all = Graph.floydWarshall(graph, (edge) => edge)
|
|
assert.strictEqual(all.distances.get(0)?.get(2), 2)
|
|
assert.deepStrictEqual(all.paths.get(0)?.get(2), [0, 1, 2])
|
|
assert.deepStrictEqual(all.edges.get(0)?.get(2), [0, 1])
|
|
assert.deepStrictEqual(all.costs.get(0)?.get(2), [1, 1])
|
|
})
|
|
})
|
|
|
|
describe("path enumeration", () => {
|
|
it("lazily and repeatably enumerates complete simple paths in edge order", () => {
|
|
const graph = directed([0, 1, 2, 3], [
|
|
[0, 1, "01"],
|
|
[0, 2, "02"],
|
|
[1, 2, "12"],
|
|
[1, 3, "13"],
|
|
[2, 3, "23"],
|
|
[2, 1, "21"]
|
|
])
|
|
const paths = Graph.simplePaths(graph, { source: 0, target: 3, limit: 3 })
|
|
const expected = [
|
|
{ path: [0, 1, 2, 3], edges: [0, 2, 4], distance: 3, costs: ["01", "12", "23"] },
|
|
{ path: [0, 1, 3], edges: [0, 3], distance: 2, costs: ["01", "13"] },
|
|
{ path: [0, 2, 3], edges: [1, 4], distance: 2, costs: ["02", "23"] }
|
|
]
|
|
assert.deepStrictEqual(Array.from(paths), expected)
|
|
assert.deepStrictEqual(Array.from(paths), expected)
|
|
assert.deepStrictEqual(Array.from(Graph.simplePaths({ source: 0, target: 3, limit: 3 })(graph)), expected)
|
|
})
|
|
|
|
it("enumerates parallel and structurally tied shortest paths exactly", () => {
|
|
const graph = directed([0, 1, 2, 3], [[0, 1, 1], [0, 2, 1], [1, 3, 1], [2, 3, 1], [0, 1, 1]])
|
|
const expected = [
|
|
{ path: [0, 1, 3], edges: [0, 2], distance: 2, costs: [1, 1] },
|
|
{ path: [0, 1, 3], edges: [4, 2], distance: 2, costs: [1, 1] },
|
|
{ path: [0, 2, 3], edges: [1, 3], distance: 2, costs: [1, 1] }
|
|
]
|
|
assert.deepStrictEqual(
|
|
Array.from(Graph.allShortestPaths(graph, { source: 0, target: 3, cost: (edge) => edge })),
|
|
expected
|
|
)
|
|
assert.deepStrictEqual(
|
|
Array.from(Graph.allShortestPaths({ source: 0, target: 3, cost: (edge: number) => edge })(graph)),
|
|
expected
|
|
)
|
|
assert.deepStrictEqual(
|
|
Array.from(Graph.allShortestPaths(Graph.beginMutation(graph), {
|
|
source: 0,
|
|
target: 3,
|
|
cost: (edge) => edge,
|
|
limit: 2
|
|
})),
|
|
expected.slice(0, 2)
|
|
)
|
|
})
|
|
|
|
it("terminates zero-cost predecessor cycles and emits only simple shortest paths", () => {
|
|
const graph = directed([0, 1, 2, 3], [[0, 1, 0], [1, 2, 0], [2, 1, 0], [1, 3, 1], [2, 3, 1]])
|
|
assert.deepStrictEqual(
|
|
Array.from(Graph.allShortestPaths(graph, { source: 0, target: 3, cost: (edge) => edge })),
|
|
[
|
|
{ path: [0, 1, 3], edges: [0, 3], distance: 1, costs: [0, 1] },
|
|
{ path: [0, 1, 2, 3], edges: [0, 1, 4], distance: 1, costs: [0, 0, 1] }
|
|
]
|
|
)
|
|
})
|
|
|
|
it("defers shortest-path cost evaluation until iteration", () => {
|
|
let calls = 0
|
|
const paths = Graph.allShortestPaths(directed([0, 1], [[0, 1, 1]]), {
|
|
source: 0,
|
|
target: 1,
|
|
cost: (edge) => {
|
|
calls++
|
|
return edge
|
|
}
|
|
})
|
|
assert.strictEqual(calls, 0)
|
|
assert.deepStrictEqual(Array.from(paths), [{ path: [0, 1], edges: [0], distance: 1, costs: [1] }])
|
|
assert.strictEqual(calls, 1)
|
|
})
|
|
|
|
it("revalidates mutable endpoints and isolates active snapshots", () => {
|
|
const removed = Graph.beginMutation(directed([0, 1], [[0, 1, 1]]))
|
|
const pending = Graph.simplePaths(removed, { source: 0, target: 1 })
|
|
Graph.removeNode(removed, 0)
|
|
assertGraphError(() => Array.from(pending), "Node 0 does not exist")
|
|
|
|
const shortestRemoved = Graph.beginMutation(directed([0, 1], [[0, 1, 1]]))
|
|
const shortestPending = Graph.allShortestPaths(shortestRemoved, {
|
|
source: 0,
|
|
target: 1,
|
|
cost: (edge) => edge
|
|
})
|
|
Graph.removeNode(shortestRemoved, 1)
|
|
assertGraphError(() => Array.from(shortestPending), "Node 1 does not exist")
|
|
|
|
const mutable = Graph.beginMutation(directed([0, 1, 2, 3], [[0, 1, 1], [0, 2, 1], [1, 3, 1], [2, 3, 1]]))
|
|
const paths = Graph.simplePaths(mutable, { source: 0, target: 3 })
|
|
const iterator = paths[Symbol.iterator]()
|
|
assert.deepStrictEqual(iterator.next().value, {
|
|
path: [0, 1, 3],
|
|
edges: [0, 2],
|
|
distance: 2,
|
|
costs: [1, 1]
|
|
})
|
|
Graph.removeEdge(mutable, 1)
|
|
assert.deepStrictEqual(iterator.next().value, {
|
|
path: [0, 2, 3],
|
|
edges: [1, 3],
|
|
distance: 2,
|
|
costs: [1, 1]
|
|
})
|
|
assert.deepStrictEqual(Array.from(paths), [{ path: [0, 1, 3], edges: [0, 2], distance: 2, costs: [1, 1] }])
|
|
|
|
const shortestMutable = Graph.beginMutation(directed([0, 1, 2, 3], [
|
|
[0, 1, 1],
|
|
[0, 2, 1],
|
|
[1, 3, 1],
|
|
[2, 3, 1]
|
|
]))
|
|
const shortest = Graph.allShortestPaths(shortestMutable, { source: 0, target: 3, cost: (edge) => edge })
|
|
const shortestIterator = shortest[Symbol.iterator]()
|
|
assert.deepStrictEqual(shortestIterator.next().value, {
|
|
path: [0, 1, 3],
|
|
edges: [0, 2],
|
|
distance: 2,
|
|
costs: [1, 1]
|
|
})
|
|
Graph.removeEdge(shortestMutable, 1)
|
|
assert.deepStrictEqual(shortestIterator.next().value, {
|
|
path: [0, 2, 3],
|
|
edges: [1, 3],
|
|
distance: 2,
|
|
costs: [1, 1]
|
|
})
|
|
assert.deepStrictEqual(Array.from(shortest), [
|
|
{ path: [0, 1, 3], edges: [0, 2], distance: 2, costs: [1, 1] }
|
|
])
|
|
})
|
|
|
|
it("handles empty path enumerations", () => {
|
|
const graph = directed<string, number>(["A", "B"], [])
|
|
const same = [{ path: [0], edges: [], distance: 0, costs: [] }]
|
|
|
|
assert.deepStrictEqual(Array.from(Graph.simplePaths(graph, { source: 0, target: 0 })), same)
|
|
assert.deepStrictEqual(
|
|
Array.from(Graph.allShortestPaths(graph, { source: 0, target: 0, cost: (edge) => edge })),
|
|
same
|
|
)
|
|
assert.deepStrictEqual(
|
|
Array.from(Graph.allShortestPaths(graph, { source: 0, target: 1, cost: (edge) => edge })),
|
|
[]
|
|
)
|
|
})
|
|
|
|
it("validates limits, endpoints, and non-negative shortest-path costs", () => {
|
|
const graph = directed([0, 1], [[0, 1, -1]])
|
|
assertGraphError(
|
|
() => Array.from(Graph.allShortestPaths(graph, { source: 0, target: 1, cost: (edge) => edge })),
|
|
"All shortest paths requires non-negative edge weights"
|
|
)
|
|
assertGraphError(
|
|
() => Array.from(Graph.allShortestPaths(graph, { source: 0, target: 1, cost: (edge) => edge, limit: 0 })),
|
|
"All shortest paths requires non-negative edge weights"
|
|
)
|
|
const overflow = directed([0, 1, 2], [[0, 1, Number.MAX_VALUE], [1, 2, Number.MAX_VALUE]])
|
|
assertGraphError(
|
|
() => Array.from(Graph.allShortestPaths(overflow, { source: 0, target: 2, cost: (edge) => edge })),
|
|
"All shortest paths distance calculation exceeded the finite number range"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.simplePaths(graph, { source: 0, target: 1, limit: -1 }),
|
|
"Path enumeration limit must be a non-negative integer or Infinity"
|
|
)
|
|
assertGraphError(() => Graph.simplePaths(graph, { source: 0, target: 2 }), "Node 2 does not exist")
|
|
assert.deepStrictEqual(Array.from(Graph.simplePaths(graph, { source: 0, target: 1, limit: 0 })), [])
|
|
})
|
|
})
|
|
|
|
describe("traversal", () => {
|
|
const graph = directed(["A", "B", "C", "D"], [[0, 1, 1], [0, 2, 2], [1, 3, 3], [2, 3, 4]])
|
|
|
|
it("traverses DFS, BFS, and postorder in documented order", () => {
|
|
assertIndices(Graph.dfs(graph, { start: [0] }), [0, 1, 3, 2])
|
|
assertIndices(Graph.bfs(graph, { start: [0] }), [0, 1, 2, 3])
|
|
assertIndices(Graph.dfsPostOrder(graph, { start: [0] }), [3, 1, 2, 0])
|
|
assertIndices(Graph.dfs(graph), [])
|
|
})
|
|
|
|
it("preserves postorder with parallel edges, self-loops, and directionless traversal", () => {
|
|
const parallel = directed([0, 1, 2, 3, 4], [
|
|
[0, 1, 1],
|
|
[0, 1, 2],
|
|
[0, 1, 3],
|
|
[1, 2, 4],
|
|
[1, 3, 5],
|
|
[1, 4, 6],
|
|
[1, 1, 7]
|
|
])
|
|
assertIndices(Graph.dfsPostOrder(parallel, { start: [0] }), [2, 3, 4, 1, 0])
|
|
assertIndices(Graph.dfsPostOrder(parallel, { start: [0, 1] }), [2, 3, 4, 1, 0])
|
|
|
|
const directionless = directed([0, 1, 2, 3], [[0, 1, 1], [2, 1, 2], [1, 3, 3], [1, 1, 4]])
|
|
assertIndices(Graph.dfsPostOrder(directionless, { start: [1], direction: "undirected" }), [3, 0, 2, 1])
|
|
})
|
|
|
|
it("uses shortest distance for radius membership and supports traversal directions", () => {
|
|
const graph = directed([0, 1, 2, 3], [[0, 1, 1], [1, 2, 1], [0, 2, 1], [2, 3, 1]])
|
|
assertIndices(Graph.dfs(graph, { start: [0], radius: 2 }), [0, 1, 2, 3])
|
|
assertIndices(Graph.bfs(graph, { start: [2], direction: "incoming" }), [2, 1, 0])
|
|
assertIndices(Graph.dfsPostOrder(graph, { start: [2], direction: "incoming" }), [0, 1, 2])
|
|
assertIndices(Graph.bfs(graph, { start: [1], direction: "undirected", radius: 1 }), [1, 2, 0])
|
|
assertIndices(
|
|
Graph.dfsPostOrder(directed([0, 1, 2], [[0, 1, 1], [1, 2, 1]]), {
|
|
start: [0],
|
|
radius: 1
|
|
}),
|
|
[1, 0]
|
|
)
|
|
})
|
|
|
|
it("preserves bounded undirected DFS order for reciprocal neighbors", () => {
|
|
const graph = directed<void, number>(new Array(5).fill(undefined), [[0, 1, 1], [4, 1, 2], [1, 4, 3]])
|
|
|
|
assertIndices(Graph.dfs(graph, { start: [1], direction: "undirected", radius: 2 }), [1, 4, 0])
|
|
})
|
|
|
|
it("validates radius in data-first and data-last forms", () => {
|
|
for (const radius of [NaN, -Infinity, -1, 0.5]) {
|
|
for (
|
|
const run of [
|
|
() => Graph.dfs(graph, { start: [0], radius }),
|
|
() => Graph.bfs({ start: [0], radius })(graph),
|
|
() => Graph.dfsPostOrder(graph, { start: [0], radius })
|
|
]
|
|
) assertGraphError(run, "Traversal radius must be a non-negative integer or Infinity")
|
|
}
|
|
})
|
|
|
|
it("copies starts, prioritizes distinct roots, and preserves sparse indexes", () => {
|
|
const start = [0]
|
|
const walkers = [Graph.dfs(graph, { start }), Graph.bfs(graph, { start }), Graph.dfsPostOrder(graph, { start })]
|
|
start[0] = 3
|
|
assertIndices(walkers[0], [0, 1, 3, 2])
|
|
assertIndices(walkers[1], [0, 1, 2, 3])
|
|
assertIndices(walkers[2], [3, 1, 2, 0])
|
|
assertIndices(Graph.bfs(graph, { start: [0, 0, 2, 2] }), [0, 2, 1, 3])
|
|
|
|
const sparse = Graph.fromSnapshot({
|
|
type: "directed",
|
|
nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 1_000_000, data: "C" }],
|
|
edges: [{ index: 3, source: 2, target: 5, data: 1 }, {
|
|
index: 1_000_000,
|
|
source: 5,
|
|
target: 1_000_000,
|
|
data: 2
|
|
}]
|
|
})
|
|
assertIndices(Graph.bfs(sparse, { start: [2] }), [2, 5, 1_000_000])
|
|
assertIndices(Graph.dfsPostOrder(sparse, { start: [2] }), [1_000_000, 5, 2])
|
|
})
|
|
|
|
it("revalidates missing starts for fresh DFS, BFS, and postorder iterators", () => {
|
|
const mutable = Graph.beginMutation(directed([0, 1], [[0, 1, 1]]))
|
|
const walkers = [
|
|
Graph.indices(Graph.dfs(mutable, { start: [0] })),
|
|
Graph.indices(Graph.bfs(mutable, { start: [0] })),
|
|
Graph.indices(Graph.dfsPostOrder(mutable, { start: [0] }))
|
|
]
|
|
Graph.removeNode(mutable, 0)
|
|
for (const walker of walkers) assertGraphError(() => walker[Symbol.iterator](), "Node 0 does not exist")
|
|
})
|
|
|
|
it("isolates active mutable traversal snapshots while fresh iterators see mutations", () => {
|
|
const mutable = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2]]))
|
|
const walkers = [
|
|
Graph.indices(Graph.dfs(mutable, { start: [0] })),
|
|
Graph.indices(Graph.bfs(mutable, { start: [0] })),
|
|
Graph.indices(Graph.dfsPostOrder(mutable, { start: [0] }))
|
|
]
|
|
const active = walkers.map((walker) => walker[Symbol.iterator]())
|
|
Graph.removeNode(mutable, 1)
|
|
assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => active[0] }), [0, 1, 2])
|
|
assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => active[1] }), [0, 1, 2])
|
|
assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => active[2] }), [2, 1, 0])
|
|
for (const walker of walkers) assert.deepStrictEqual(Array.from(walker), [0])
|
|
})
|
|
|
|
it("topologically sorts with prioritized initials and validates fresh mutable state", () => {
|
|
const graph = directed(["A", "B", "C", "D"], [[0, 1, 1], [2, 3, 1]])
|
|
assertIndices(Graph.topo(graph, { initials: [2] }), [2, 0, 3, 1])
|
|
|
|
const mutable = Graph.beginMutation(directed<string, number>(["A", "B"], []))
|
|
const walker = Graph.topo(mutable)
|
|
assertIndices(walker, [0, 1])
|
|
Graph.addEdge(mutable, 0, 1, 1)
|
|
Graph.addEdge(mutable, 1, 0, 2)
|
|
assertGraphError(() => Array.from(Graph.indices(walker)), "Cannot perform topological sort on cyclic graph")
|
|
})
|
|
|
|
it("isolates active mutable topological snapshots while fresh iterators see mutations", () => {
|
|
const mutable = Graph.beginMutation(directed([0, 1, 2], [[0, 1, 1], [1, 2, 1]]))
|
|
const walker = Graph.indices(Graph.topo(mutable))
|
|
const active = walker[Symbol.iterator]()
|
|
Graph.removeNode(mutable, 1)
|
|
|
|
assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => active }), [0, 1, 2])
|
|
assert.deepStrictEqual(Array.from(walker), [0, 2])
|
|
})
|
|
|
|
it("rejects invalid topological graphs and initials", () => {
|
|
assertGraphError(
|
|
() => Graph.topo(Graph.undirected() as unknown as Graph.DirectedGraph<never, never>),
|
|
"Cannot perform topological sort on undirected graph"
|
|
)
|
|
assertGraphError(
|
|
() => Graph.topo(directed([0, 1], [[0, 1, 1], [1, 0, 1]])),
|
|
"Cannot perform topological sort on cyclic graph"
|
|
)
|
|
const graph = directed([0, 1], [[0, 1, 1]])
|
|
assertGraphError(() => Array.from(Graph.topo(graph, { initials: [1] })), "Initial node 1 has incoming edges")
|
|
assertGraphError(() => Graph.topo(graph, { initials: [2] }), "Node 2 does not exist")
|
|
})
|
|
|
|
it("keeps nodes, edges, and externals live on mutable graphs", () => {
|
|
const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]]))
|
|
const nodes = Graph.indices(Graph.nodes(mutable))[Symbol.iterator]()
|
|
const edges = Graph.indices(Graph.edges(mutable))[Symbol.iterator]()
|
|
const externals = Graph.indices(Graph.externals(mutable))[Symbol.iterator]()
|
|
assert.strictEqual(nodes.next().value, 0)
|
|
assert.strictEqual(edges.next().value, 0)
|
|
assert.strictEqual(externals.next().value, 1)
|
|
|
|
Graph.addNode(mutable, "C")
|
|
Graph.addEdge(mutable, 1, 2, 2)
|
|
assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => nodes }), [1, 2])
|
|
assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => edges }), [1])
|
|
assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => externals }), [2])
|
|
})
|
|
|
|
it("selects outgoing sinks and incoming sources", () => {
|
|
const graph = directed(["source", "middle", "sink", "isolated"], [[0, 1, 1], [1, 2, 2]])
|
|
assertIndices(Graph.externals(graph), [2, 3])
|
|
assertIndices(Graph.externals(graph, { direction: "outgoing" }), [2, 3])
|
|
assertIndices(Graph.externals(graph, { direction: "incoming" }), [0, 3])
|
|
})
|
|
})
|
|
|
|
describe("Walker", () => {
|
|
it("projects representative walkers through indices, values, entries, and visit", () => {
|
|
const walker = new Graph.Walker<number, string>(function*(visit) {
|
|
yield visit(2, "A")
|
|
yield visit(5, "B")
|
|
})
|
|
|
|
assert.deepStrictEqual(Array.from(Graph.indices(walker)), [2, 5])
|
|
assert.deepStrictEqual(Array.from(Graph.values(walker)), ["A", "B"])
|
|
assert.deepStrictEqual(Array.from(Graph.entries(walker)), [[2, "A"], [5, "B"]])
|
|
assert.deepStrictEqual(Array.from(walker.visit((index, value) => `${index}:${value}`)), ["2:A", "5:B"])
|
|
})
|
|
|
|
it("is repeatable and gives independent iterators fresh state", () => {
|
|
const walker = new Graph.Walker<number, string>(function*(visit) {
|
|
yield visit(0, "A")
|
|
yield visit(1, "B")
|
|
})
|
|
assert.deepStrictEqual(Array.from(walker), [[0, "A"], [1, "B"]])
|
|
assert.deepStrictEqual(Array.from(walker), [[0, "A"], [1, "B"]])
|
|
|
|
const left = walker[Symbol.iterator]()
|
|
const right = walker[Symbol.iterator]()
|
|
assert.deepStrictEqual(left.next(), { done: false, value: [0, "A"] })
|
|
assert.deepStrictEqual(right.next(), { done: false, value: [0, "A"] })
|
|
assert.deepStrictEqual(left.next(), { done: false, value: [1, "B"] })
|
|
assert.deepStrictEqual(right.next(), { done: false, value: [1, "B"] })
|
|
})
|
|
|
|
it("preserves receivers supplied by iterable iterator methods", () => {
|
|
const walker = new Graph.Walker<number, string>((visit) => new Set([visit(0, "A"), visit(1, "B")]))
|
|
assert.deepStrictEqual(Array.from(walker), [[0, "A"], [1, "B"]])
|
|
})
|
|
})
|
|
})
|