diff --git a/build.sbt b/build.sbt index c999d66..b985921 100644 --- a/build.sbt +++ b/build.sbt @@ -35,6 +35,12 @@ val Deps = new { } val Gs = new { + val Std = new { + private val Version: String = "0.1.3" + + val Core: ModuleID = "gs" %% "gs-std-core-v0" % Version + } + val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.4.1" } @@ -61,6 +67,7 @@ lazy val core = project .settings(sharedSettings) .settings(testSettings) .settings(name := s"${gsProjectName.value}-core-v${semVerMajor.value}") + .settings(libraryDependencies ++= Seq(Deps.Gs.Std.Core)) lazy val cats = project .in(file("modules/cats")) diff --git a/modules/core/src/main/scala/gs/graph/v0/Adjacency.scala b/modules/core/src/main/scala/gs/graph/v0/Adjacency.scala index 16e4dbb..fcce9de 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Adjacency.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Adjacency.scala @@ -133,10 +133,10 @@ object Adjacency: ): Adjacency = val buffs = Vector.fill(numberOfVertices.value)(ListBuffer.empty[Vertex]) val _ = edges.foreach { edge => - if edge.v1 >= numberOfVertices || edge.v2 >= numberOfVertices then - throw new IllegalArgumentException( - s"Edge (${edge.v1}, ${edge.v2}) is out of bounds. Maximum vertex value is ${numberOfVertices.value - 1}" - ) + if edge.v1 >= numberOfVertices then + throw GraphException.VertexExceedsGraphBounds(edge.v1, numberOfVertices) + else if edge.v2 >= numberOfVertices then + throw GraphException.VertexExceedsGraphBounds(edge.v2, numberOfVertices) else val _ = buffs(edge.from.ordinal).addOne(edge.to) } diff --git a/modules/core/src/main/scala/gs/graph/v0/ConnectedComponent.scala b/modules/core/src/main/scala/gs/graph/v0/ConnectedComponent.scala new file mode 100644 index 0000000..2185943 --- /dev/null +++ b/modules/core/src/main/scala/gs/graph/v0/ConnectedComponent.scala @@ -0,0 +1,135 @@ +package gs.graph.v0 + +import gs.graph.v0.Vertex +import scala.collection.mutable.ListBuffer +import scala.collection.mutable.Queue + +/** Represents a _ Connected Component_. + * + * TODO: ADD EDGES! + */ +final class ConnectedComponent private (val value: Set[Vertex]): + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: ConnectedComponent => value.equals(other.value) + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = + val sb = StringBuilder() + sb.append("[") + sb.append(value.mkString(",")) + sb.append("]") + sb.toString() + + /** @return + * The number of vertices contained by this component. + */ + def size: Int = value.size + + /** @return + * True if this component contains a single vertex. + */ + def isSingle: Boolean = value.size == 1 + + /** @return + * The set of vertices in the Connected Component. + */ + def vertices: Set[Vertex] = value + + /** Assign an arbitrary vertex as the root vertex. + */ + lazy val root: Vertex = value.toList.head + +end ConnectedComponent + +object ConnectedComponent: + + /** Instantiate a new Connected Component from the given collection of + * [[Vertex]]. + * + * This constructor ensures that each [[Vertex]] only occurs once. + * + * @param value + * The input collection. + * @return + * The new Connected Component. + */ + def apply(value: Vertex*): ConnectedComponent = + if value.isEmpty then + throw new IllegalArgumentException( + "Empty connected components are not allowed." + ) + else new ConnectedComponent(value.toSet) + + def of(value: Int*): ConnectedComponent = + if value.isEmpty then + throw new IllegalArgumentException( + "Empty connected components are not allowed." + ) + else new ConnectedComponent(value.map(Vertex.apply).toSet) + + /** Instantiate a new Connected Component that contains a single [[Vertex]]. + * + * @param value + * The input vertex. + * @return + * The new Connected Component. + */ + def single(value: Vertex): ConnectedComponent = + new ConnectedComponent(Set(value)) + + given CanEqual[ConnectedComponent, ConnectedComponent] = CanEqual.derived + + def findAll(g: Graph): List[ConnectedComponent] = + if g.isEmpty then Nil + else + val tracker = Tracker.initialize(g) + val ccs = ListBuffer.empty[ConnectedComponent] + (0 until g.numberOfVertices.value).foreach { ordinal => + val v = Vertex(ordinal) + if !tracker.isVisited(v) then ccs.addOne(bfsFromVertex(g, v, tracker)) + else () + } + ccs.toList + + private def bfsFromVertex( + g: Graph, + v: Vertex, + t: Tracker + ): ConnectedComponent = + val q = Queue.empty[Vertex] + val b = ListBuffer.empty[Vertex] + q.enqueue(v) + + while !q.isEmpty + do + val current = q.dequeue() + t.visit(current) + b.addOne(current) + g.neighbors(current).foreach { neighbor => + if !t.isVisited(neighbor) then q.enqueue(neighbor) + else () + } + + new ConnectedComponent(b.toSet) + + private class Tracker(val data: Array[Boolean]): + def visit(v: Vertex): Unit = data(v.ordinal) = true + + def isVisited(v: Vertex): Boolean = data(v.ordinal) + + private object Tracker: + + def initialize(g: Graph): Tracker = + new Tracker(Array.fill(g.numberOfVertices.value)(false)) + +end ConnectedComponent diff --git a/modules/core/src/main/scala/gs/graph/v0/Graph.scala b/modules/core/src/main/scala/gs/graph/v0/Graph.scala index 9ea539f..7011cd5 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Graph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Graph.scala @@ -1,5 +1,7 @@ package gs.graph.v0 +import gs.graph.v0.directed.Digraph + /** Graph representation based on an adjacency list. * * See: @@ -32,6 +34,11 @@ trait Graph: */ def isEmpty: Boolean = numberOfVertices == Size.Zero + /** @return + * True if this graph has one vertex, false otherwise. + */ + def isSingle: Boolean = numberOfVertices == Size.One + /** @return * The roots that should be used for traversal operations. */ @@ -95,4 +102,22 @@ object Graph: adj ) + /** Construct a new [[Digraph]] from the given [[Edge]]. + * + * @param numberOfVertices + * The number of vertices in this [[Graph]]. + * @param edges + * The [[Edge]] in this graph. + * @return + * The new [[Digraph]]. + */ + def directed( + numberOfVertices: Size, + edges: (Int, Int)* + ): Digraph = + Digraph.fromEdges( + numberOfVertices, + edges.map(pair => Edge(pair._1, pair._2)) + ) + end Graph diff --git a/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala b/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala index 211d621..4e52df4 100644 --- a/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala @@ -19,22 +19,20 @@ class UndirectedGraph( */ override def selectRoots(): Vector[Vertex] = roots - /** The roots of an undirected graph are identified by arbitrarily selecting - * the first listed vertex from each strongly connected component in the - * graph. + /** The lazily calculated roots of this graph. + * + * In an undirected graph, the roots are calculated by selecting an arbitrary + * vertex from each connected component in the graph. */ lazy val roots: Vector[Vertex] = calculateRoots() private def calculateRoots(): Vector[Vertex] = if numberOfVertices == Size.Zero then Vector.empty - else oneRootFromEachSCC(calculateSCCs()).toVector + else oneFromEachConnectedComponent().toVector - private def calculateSCCs(): List[SCC] = - SCC.findAll(this) - - private def oneRootFromEachSCC(sccs: List[SCC]): List[Vertex] = - sccs.map(_.root) + private def oneFromEachConnectedComponent(): List[Vertex] = + ConnectedComponent.findAll(this).map(_.root) end UndirectedGraph diff --git a/modules/core/src/main/scala/gs/graph/v0/Vertex.scala b/modules/core/src/main/scala/gs/graph/v0/Vertex.scala index 1e35add..73b6e11 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Vertex.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Vertex.scala @@ -130,6 +130,24 @@ object Vertex: else throw new IllegalArgumentException("Vertex values must be 0 or greater.") + /** Instantiate an immutable sequence of vertices. + * + * Throws an `IllegalArgumentException` if any input value is less than 0. + * + * @param values + * The integer values to validate. + * @return + * The new vertices. + */ + def of(values: Int*): Vector[Vertex] = + values.map(apply).toVector + + def list(values: Int*): List[Vertex] = + values.map(apply).toList + + def set(values: Int*): Set[Vertex] = + values.map(apply).toSet + given CanEqual[Vertex, Vertex] = CanEqual.derived given Ordering[Vertex] with diff --git a/modules/core/src/main/scala/gs/graph/v0/directed/Dag.scala b/modules/core/src/main/scala/gs/graph/v0/directed/Dag.scala index 355d8e5..a2892f6 100644 --- a/modules/core/src/main/scala/gs/graph/v0/directed/Dag.scala +++ b/modules/core/src/main/scala/gs/graph/v0/directed/Dag.scala @@ -18,6 +18,16 @@ class Dag protected ( object Dag: + /** The empty DAG. + */ + final val Empty: Dag = + new Dag(Size.Zero, Adjacency.Empty, Vector.empty) + + /** DAG with a single vertex. + */ + final val Single: Dag = + new Dag(Size.One, Adjacency.Single, Vector(Vertex.Zero)) + given CanEqual[Dag, Dag] = CanEqual.derived given CanEqual[Dag, Digraph] = CanEqual.derived given CanEqual[Digraph, Dag] = CanEqual.derived diff --git a/modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala b/modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala index 4d75708..9e3bd40 100644 --- a/modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala @@ -36,9 +36,12 @@ class Digraph( case other: Digraph => numberOfVertices == other.numberOfVertices && adjacency == other.adjacency - && roots.sameElements(other.roots) case _ => false + /** `True` if this directed graph has a cycle, `false` otherwise. + */ + lazy val hasCycle: Boolean = Digraph.hasCycle(this) + end Digraph object Digraph: @@ -121,9 +124,10 @@ object Digraph: */ def hasCycle(digraph: Digraph): Boolean = digraph match - case _ if digraph.isEmpty => false - case _: Dag => false - case _ => new CycleDetector(digraph).hasCycle + case _ if digraph.isEmpty => false + case _ if digraph.isSingle => false + case _: Dag => false + case _ => new CycleDetector(digraph).hasCycle // Internal class for finding cycles. // This implementation comes (roughly) from Algorithms (Fourth Edition) by diff --git a/modules/core/src/main/scala/gs/graph/v0/SCC.scala b/modules/core/src/main/scala/gs/graph/v0/directed/StronglyConnectedComponent.scala similarity index 79% rename from modules/core/src/main/scala/gs/graph/v0/SCC.scala rename to modules/core/src/main/scala/gs/graph/v0/directed/StronglyConnectedComponent.scala index 7e1b6b2..37b28f8 100644 --- a/modules/core/src/main/scala/gs/graph/v0/SCC.scala +++ b/modules/core/src/main/scala/gs/graph/v0/directed/StronglyConnectedComponent.scala @@ -1,6 +1,5 @@ -package gs.graph.v0 +package gs.graph.v0.directed -import gs.graph.v0.Graph import gs.graph.v0.Vertex import scala.collection.mutable.ListBuffer import scala.collection.mutable.Stack @@ -8,15 +7,20 @@ import scala.collection.mutable.Stack /** Represents a _Strongly Connected Component_ (SCC). * * Each SCC is guaranteed to be non-empty. + * + * This class is used with [[Digraph]]. See [[gs.graph.v0.ConnectedComponent]] + * for general connected components. + * + * TODO: ADD EDGES */ -final class SCC private (val value: Set[Vertex]): +final class StronglyConnectedComponent private (val value: Set[Vertex]): /** @inheritDocs */ override def equals(obj: Any): Boolean = obj match - case other: SCC => value.equals(other.value) - case _ => false + case other: StronglyConnectedComponent => value.equals(other.value) + case _ => false /** @inheritDocs */ @@ -50,9 +54,9 @@ final class SCC private (val value: Set[Vertex]): */ lazy val root: Vertex = value.toList.head -end SCC +end StronglyConnectedComponent -object SCC: +object StronglyConnectedComponent: /** Instantiate a new SCC from the given collection of [[Vertex]]. * @@ -63,19 +67,19 @@ object SCC: * @return * The new SCC. */ - def apply(value: Vertex*): SCC = + def apply(value: Vertex*): StronglyConnectedComponent = if value.isEmpty then throw new IllegalArgumentException( "Empty strongly connected components (SCC) are not allowed." ) - else new SCC(value.toSet) + else new StronglyConnectedComponent(value.toSet) - def of(value: Int*): SCC = + def of(value: Int*): StronglyConnectedComponent = if value.isEmpty then throw new IllegalArgumentException( "Empty strongly connected components (SCC) are not allowed." ) - else new SCC(value.map(Vertex.apply).toSet) + else new StronglyConnectedComponent(value.map(Vertex.apply).toSet) /** Instantiate a new SCC that contains a single [[Vertex]]. * @@ -84,9 +88,11 @@ object SCC: * @return * The new SCC. */ - def single(value: Vertex): SCC = new SCC(Set(value)) + def single(value: Vertex): StronglyConnectedComponent = + new StronglyConnectedComponent(Set(value)) - given CanEqual[SCC, SCC] = CanEqual.derived + given CanEqual[StronglyConnectedComponent, StronglyConnectedComponent] = + CanEqual.derived /** Alias for `findAll`. * @@ -101,7 +107,7 @@ object SCC: * @return * The complete list of [[SCC]] for the input graph. */ - def tarjan(g: Graph): List[SCC] = findAll(g) + def tarjan(g: Digraph): List[StronglyConnectedComponent] = findAll(g) /** Implementation of Tarjan's Algorithm for finding all strongly connected * components for some graph. @@ -114,7 +120,7 @@ object SCC: * @return * The complete list of [[SCC]] for the input graph. */ - def findAll(g: Graph): List[SCC] = + def findAll(g: Digraph): List[StronglyConnectedComponent] = val V = g.numberOfVertices.value // Vertex numbers. @@ -139,7 +145,7 @@ object SCC: val counter = new Counter(0) // List of output SCCs. - val sccs = ListBuffer.empty[SCC] + val sccs = ListBuffer.empty[StronglyConnectedComponent] (0 until V).foreach { v => if !visited(v) then @@ -151,7 +157,7 @@ object SCC: sccs.toList private def tarjanDfs( - g: Graph, + g: Digraph, v: Int, num: Array[Int], lowest: Array[Int], @@ -159,7 +165,7 @@ object SCC: processed: Array[Boolean], s: Stack[Vertex], counter: Counter, - output: ListBuffer[SCC] + output: ListBuffer[StronglyConnectedComponent] ): Unit = // Convenience integer representation for array access. val V = Vertex(v) @@ -195,7 +201,8 @@ object SCC: sccVertex = s.pop() scc.addOne(sccVertex) - if !scc.isEmpty then output.addOne(new SCC(scc.toSet)) + if !scc.isEmpty then + output.addOne(new StronglyConnectedComponent(scc.toSet)) else println("ERROR") else () @@ -213,4 +220,4 @@ object SCC: end Counter -end SCC +end StronglyConnectedComponent diff --git a/modules/core/src/main/scala/gs/graph/v0/syntax/extensions.scala b/modules/core/src/main/scala/gs/graph/v0/syntax/extensions.scala index 69c68e9..98016b3 100644 --- a/modules/core/src/main/scala/gs/graph/v0/syntax/extensions.scala +++ b/modules/core/src/main/scala/gs/graph/v0/syntax/extensions.scala @@ -1,10 +1,26 @@ package gs.graph.v0.syntax +import gs.graph.v0.Edge import gs.graph.v0.Graph import gs.graph.v0.GraphTraversal import gs.graph.v0.Vertex import gs.graph.v0.data.AnyGraphWithData +/** Construction helper for [[Vertex]] values. + * + * @param value + * The value of the vertex. + * @return + * The new [[Vertex]] instance. + */ +def V(value: Int): Vertex = Vertex(value) + +extension (value: (Int, Int)) + /** @return + * The [[Edge]] represented by this tuple. + */ + def edge: Edge = Edge(value._1, value._2) + extension (graph: Graph) /** Visit vertex node in this graph, using Depth-First Search (DFS) as the diff --git a/modules/core/src/test/scala/gs/graph/v0/AdjacencyTests.scala b/modules/core/src/test/scala/gs/graph/v0/AdjacencyTests.scala index ad3af26..7fe634e 100644 --- a/modules/core/src/test/scala/gs/graph/v0/AdjacencyTests.scala +++ b/modules/core/src/test/scala/gs/graph/v0/AdjacencyTests.scala @@ -1,12 +1,12 @@ package gs.graph.v0 +import gs.graph.v0.syntax.V import scala.util.Failure import scala.util.Try class AdjacencyTests extends munit.FunSuite: private val size = Size(7) - private val vs = (0 until size.value).map(Vertex(_)).toArray private val edges = Edge.list( 0 -> 1, @@ -32,13 +32,13 @@ class AdjacencyTests extends munit.FunSuite: } test("should provide incoming connections") { - assertEquals(adj.incoming(vs(0)), Vector.empty) - assertEquals(adj.incoming(vs(1)), Vector(vs(0))) - assertEquals(adj.incoming(vs(2)), Vector(vs(0))) - assertEquals(adj.incoming(vs(3)), Vector(vs(0))) - assertEquals(adj.incoming(vs(4)), Vector(vs(1), vs(2), vs(3))) - assertEquals(adj.incoming(vs(5)), Vector(vs(3))) - assertEquals(adj.incoming(vs(6)), Vector(vs(4))) + assertEquals(adj.incoming(V(0)), Vector.empty) + assertEquals(adj.incoming(V(1)), Vertex.of(0)) + assertEquals(adj.incoming(V(2)), Vertex.of(0)) + assertEquals(adj.incoming(V(3)), Vertex.of(0)) + assertEquals(adj.incoming(V(4)), Vertex.of(1, 2, 3)) + assertEquals(adj.incoming(V(5)), Vertex.of(3)) + assertEquals(adj.incoming(V(6)), Vertex.of(4)) } test("should show no incoming connections for a vertex not in the graph") { @@ -66,19 +66,32 @@ class AdjacencyTests extends munit.FunSuite: } test( - "should guard against out of bound values when building from edges with an explicit bound" + "should guard against out of bound values when building from edges with an explicit bound (first vertex)" + ) { + val n = Size(2) + val es = Edge.list(3 -> 0) + Try(Adjacency.fromEdges(n, es)) match + case Failure(ex: GraphException.VertexExceedsGraphBounds) => + assertEquals(ex.vertex, Vertex(3)) + assertEquals(ex.bound, n) + case _ => + fail( + "Expected adjacency construction to fail with a GraphException." + ) + } + + test( + "should guard against out of bound values when building from edges with an explicit bound (second vertex)" ) { val n = Size(2) val es = Edge.list(0 -> 3) Try(Adjacency.fromEdges(n, es)) match - case Failure(ex: IllegalArgumentException) => - assertEquals( - ex.getMessage(), - "Edge (0, 3) is out of bounds. Maximum vertex value is 1" - ) + case Failure(ex: GraphException.VertexExceedsGraphBounds) => + assertEquals(ex.vertex, Vertex(3)) + assertEquals(ex.bound, n) case _ => fail( - "Expected adjacency construction to fail with an IllegalArgumentException." + "Expected adjacency construction to fail with a GraphException." ) } diff --git a/modules/core/src/test/scala/gs/graph/v0/UndirectedGraphTests.scala b/modules/core/src/test/scala/gs/graph/v0/UndirectedGraphTests.scala index dda6fdb..def71cc 100644 --- a/modules/core/src/test/scala/gs/graph/v0/UndirectedGraphTests.scala +++ b/modules/core/src/test/scala/gs/graph/v0/UndirectedGraphTests.scala @@ -12,8 +12,48 @@ class UndirectedGraphTests extends FunSuite: assertEquals(g.selectRoots(), Vector.empty) } - test( - "should calculate roots based on an arbitrary vertex from each connected component" - ) { - // TODO: implement - current logic is wrong. + test("should construct a graph based on # of edges and edge list") { + val N = Size(3) + val g = Graph.undirected( + numberOfVertices = N, + edges = 0 -> 1, + 1 -> 2, + 0 -> 2 + ) + + assertEquals(g.numberOfVertices, N) + assertEquals(g.neighbors(Vertex(0)), Vector(Vertex(1), Vertex(2))) + assertEquals(g.neighbors(Vertex(4)), Vector.empty) + assertEquals(g.selectRoots(), Vector(Vertex.Zero)) + } + + test("should construct a graph based on edge list") { + val N = Size(3) + val g = Graph.undirected( + edges = 0 -> 1, + 1 -> 2, + 0 -> 2 + ) + + assertEquals(g.numberOfVertices, N) + assertEquals(g.neighbors(Vertex(0)), Vector(Vertex(1), Vertex(2))) + assertEquals(g.neighbors(Vertex(4)), Vector.empty) + assertEquals(g.selectRoots(), Vector(Vertex.Zero)) + } + + test("should calculate roots for each connected component") { + val N = Size(10) + val g = Graph.undirected( + numberOfVertices = N, + edges = 0 -> 1, + 1 -> 2, + 2 -> 3, + 4 -> 5, + 5 -> 6, + 4 -> 6, + 7 -> 8 + ) + val expectedRoots = Vertex.of(0, 4, 7, 9) + + assertEquals(g.roots, expectedRoots) } diff --git a/modules/core/src/test/scala/gs/graph/v0/directed/DagTests.scala b/modules/core/src/test/scala/gs/graph/v0/directed/DagTests.scala index b9b6570..a42c1af 100644 --- a/modules/core/src/test/scala/gs/graph/v0/directed/DagTests.scala +++ b/modules/core/src/test/scala/gs/graph/v0/directed/DagTests.scala @@ -20,6 +20,15 @@ class DagTests extends FunSuite: fail("Expected a graph with one vertex to be validated as a DAG.") } + test("should not have cycles by definition") { + Dag.validate(Digraph.Single) match + case Right(dag) => + assertEquals(Digraph.hasCycle(dag), false) + assertEquals(dag.hasCycle, false) + case _ => + fail("Expected a graph with one vertex to be validated as a DAG.") + } + test("should validate a single-root graph") { val size = Size(8) val digraph: Digraph = Digraph.fromAdjacency( @@ -40,7 +49,10 @@ class DagTests extends FunSuite: ) Dag.validate(digraph) match - case Right(dag) => assertEquals(dag, digraph) + case Right(dag) => + assertEquals(dag, digraph) + assertEquals(Digraph.hasCycle(dag), false) + assertEquals(dag.hasCycle, false) case _ => fail("Expected a single-root graph to be validated as a DAG.") } @@ -65,3 +77,8 @@ class DagTests extends FunSuite: ) case _ => () } + + test("should provide empty and single graphs") { + assertEquals(Dag.Empty, Digraph.Empty) + assertEquals(Dag.Single, Digraph.Single) + } diff --git a/modules/core/src/test/scala/gs/graph/v0/directed/DigraphTests.scala b/modules/core/src/test/scala/gs/graph/v0/directed/DigraphTests.scala new file mode 100644 index 0000000..c2979bd --- /dev/null +++ b/modules/core/src/test/scala/gs/graph/v0/directed/DigraphTests.scala @@ -0,0 +1,92 @@ +package gs.graph.v0.directed + +import gs.graph.v0.Graph +import gs.graph.v0.Size +import gs.graph.v0.Vertex +import munit.* + +class DigraphTests extends FunSuite: + + test("should provide an empty graph") { + val dg = Digraph.Empty + assertEquals(dg.numberOfVertices, Size.Zero) + assertEquals(dg.isSingle, false) + assertEquals(dg.roots, Vector.empty) + assertEquals(dg.isEmpty, true) + assertEquals(dg.neighbors(Vertex.Zero), Vector.empty) + assertEquals(dg.hasCycle, false) + } + + test("should provide a single-node graph") { + val dg = Digraph.Single + assertEquals(dg.numberOfVertices, Size.One) + assertEquals(dg.isSingle, true) + assertEquals(dg.roots, Vertex.of(0)) + assertEquals(dg.isEmpty, false) + assertEquals(dg.neighbors(Vertex.Zero), Vector.empty) + assertEquals(dg.hasCycle, false) + } + + test("should not equal non-digraph types") { + val dg = Digraph.Empty + assertEquals(dg.equals(null), false) + assertEquals(dg.equals("foo"), false) + assertEquals(dg.equals(1), false) + } + + test("should not equal a digraph of a different size") { + assertNotEquals(Digraph.Empty, Digraph.Single) + } + + test("should not equal a digraph with different edges") { + val N = Size(3) + val d1 = Graph.directed( + numberOfVertices = N, + edges = 0 -> 1, + 1 -> 2 + ) + val d2 = Graph.directed( + numberOfVertices = N, + edges = 0 -> 1, + 1 -> 2, + 0 -> 2 + ) + assertNotEquals(d1, d2) + } + + test( + "should detect cycles where a single component has a cycle back to the root" + ) { + val N = Size(3) + val dg = Graph.directed( + numberOfVertices = N, + edges = 0 -> 1, + 1 -> 2, + 2 -> 0 + ) + assertEquals(dg.hasCycle, true) + } + + test("should detect cycles where one component does not have a cycle") { + val N = Size(6) + val dg = Graph.directed( + numberOfVertices = N, + edges = 0 -> 1, + 1 -> 2, + 3 -> 4, + 4 -> 5, + 5 -> 3 + ) + assertEquals(dg.hasCycle, true) + } + + test("should detect cycles within a subset of a component") { + val N = Size(3) + val dg = Graph.directed( + numberOfVertices = N, + edges = 0 -> 1, + 1 -> 2, + 2 -> 1 + ) + assertEquals(dg.hasCycle, true) + } diff --git a/modules/core/src/test/scala/gs/graph/v0/TarjansTests.scala b/modules/core/src/test/scala/gs/graph/v0/directed/TarjansTests.scala similarity index 62% rename from modules/core/src/test/scala/gs/graph/v0/TarjansTests.scala rename to modules/core/src/test/scala/gs/graph/v0/directed/TarjansTests.scala index 3387cdb..6b49a8f 100644 --- a/modules/core/src/test/scala/gs/graph/v0/TarjansTests.scala +++ b/modules/core/src/test/scala/gs/graph/v0/directed/TarjansTests.scala @@ -1,6 +1,10 @@ -package gs.graph.v0 +package gs.graph.v0.directed -import gs.graph.v0.directed.Digraph +import gs.graph.v0.Adjacency +import gs.graph.v0.Edge +import gs.graph.v0.Graph +import gs.graph.v0.Size +import gs.graph.v0.Vertex import munit.* class TarjansTests extends FunSuite: @@ -9,9 +13,9 @@ class TarjansTests extends FunSuite: val v0 = Vertex.Zero val v1 = Vertex(1) val v2 = Vertex(2) - val s1 = SCC(v0) - val s2 = SCC(v0, v1, v2) - val s3 = SCC(v0) + val s1 = StronglyConnectedComponent(v0) + val s2 = StronglyConnectedComponent(v0, v1, v2) + val s3 = StronglyConnectedComponent(v0) assertEquals(s1, s3) assertNotEquals(s1, s2) @@ -23,9 +27,9 @@ class TarjansTests extends FunSuite: } test("should produce an empty list of SCCs for an empty graph") { - val g = UndirectedGraph.Empty - val sccs = SCC.tarjan(g) - val alt = SCC.findAll(g) + val g = Digraph.Empty + val sccs = StronglyConnectedComponent.tarjan(g) + val alt = StronglyConnectedComponent.findAll(g) assertEquals(sccs, Nil) assertEquals(alt, Nil) } @@ -33,9 +37,9 @@ class TarjansTests extends FunSuite: test( "should produce a list of once SCC with one vertex for a graph with one vertex" ) { - val g = Graph.undirected(Size.One) - val sccs = SCC.findAll(g) - val expected = SCC.single(Vertex.Zero) + val g = Graph.directed(Size.One) + val sccs = StronglyConnectedComponent.findAll(g) + val expected = StronglyConnectedComponent.single(Vertex.Zero) assertEquals(sccs, List(expected)) } @@ -55,9 +59,16 @@ class TarjansTests extends FunSuite: ) ) - val sccs = SCC.findAll(g) + val sccs = StronglyConnectedComponent.findAll(g) assertEquals(g.roots, Vector(Vertex.Zero)) - assertEquals(sccs.toSet, Set(SCC.of(0), SCC.of(1), SCC.of(2))) + assertEquals( + sccs.toSet, + Set( + StronglyConnectedComponent.of(0), + StronglyConnectedComponent.of(1), + StronglyConnectedComponent.of(2) + ) + ) } test("should handle a graph with multiple SCCs") { @@ -87,14 +98,14 @@ class TarjansTests extends FunSuite: ) ) - val sccs = SCC.findAll(g) + val sccs = StronglyConnectedComponent.findAll(g) assertEquals(g.roots, Vector.empty) assertEquals( sccs.toSet, Set( - SCC.of(0, 1, 2), - SCC.of(3, 4, 5), - SCC.of(6, 7, 8) + StronglyConnectedComponent.of(0, 1, 2), + StronglyConnectedComponent.of(3, 4, 5), + StronglyConnectedComponent.of(6, 7, 8) ) ) }