diff --git a/build.sbt b/build.sbt index 271f169..c999d66 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3: String = "3.8.2" +val scala3: String = "3.8.3" ThisBuild / scalaVersion := scala3 ThisBuild / versionScheme := Some("semver-spec") @@ -38,7 +38,7 @@ val Deps = new { val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.4.1" } - val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.2.4" + val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.3.0" } lazy val testSettings = Seq( 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 0afa1bb..16e4dbb 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Adjacency.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Adjacency.scala @@ -8,7 +8,7 @@ import scala.collection.mutable.ListBuffer * the corresponding vector is a "to" [[Vertex]] -- there are edges _from_ some * vertex _to_ another vertex. */ -final class Adjacency(val neighbors: Vector[Vector[Vertex]]): +final class Adjacency private (val neighbors: Vector[Vector[Vertex]]): /** Get the vector of [[Vertex]] that receive a connection _from_ the input * [[Vertex]]. * @@ -103,7 +103,7 @@ object Adjacency: * @return * Some new empty adjacency. */ - def empty(numberOfVertices: Size): Adjacency = + def noEdges(numberOfVertices: Size): Adjacency = new Adjacency(Vector.fill(numberOfVertices.value)(Vector.empty)) given CanEqual[Adjacency, Adjacency] = CanEqual.derived @@ -118,8 +118,7 @@ object Adjacency: */ final val Single: Adjacency = new Adjacency(Vector(Vector.empty)) - /** Calculate an [[Adjacency]] from some collection of [[Edge]], where those - * edges are assumed to be directed. + /** Calculate an [[Adjacency]] from some collection of [[Edge]]. * * @param numberOfVertices * The number of [[Vertex]] (`N`) in this graph. @@ -128,14 +127,44 @@ object Adjacency: * @return * The calculated [[Adjacency]]. */ - def fromDirectedEdges( + def fromEdges( numberOfVertices: Size, edges: Iterable[Edge] ): 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}" + ) + else + val _ = buffs(edge.from.ordinal).addOne(edge.to) + } + new Adjacency(buffs.map(_.distinct.toVector)) + + /** Calculate an [[Adjacency]] from some collection of [[Edge]]. + * + * @param edges + * The collection of [[Edge]] present in this graph. + * @return + * The calculated [[Adjacency]]. + */ + def fromEdges( + edges: Iterable[Edge] + ): Adjacency = + val numberOfVertices = findMaximumVertex(edges).ordinal + 1 + val buffs = Vector.fill(numberOfVertices)(ListBuffer.empty[Vertex]) val _ = edges.foreach { edge => val _ = buffs(edge.from.ordinal).addOne(edge.to) } new Adjacency(buffs.map(_.distinct.toVector)) + private def findMaximumVertex(edges: Iterable[Edge]): Vertex = + var maximum = Vertex.Zero + edges.foreach { edge => + if edge.max > maximum then maximum = edge.max + else () + } + maximum + end Adjacency diff --git a/modules/core/src/main/scala/gs/graph/v0/Edge.scala b/modules/core/src/main/scala/gs/graph/v0/Edge.scala index 71d2435..bd3ab5b 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Edge.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Edge.scala @@ -2,7 +2,7 @@ package gs.graph.v0 import java.util.Objects -/** Represents a relationship between two [[Vertex]]. +/** Represents a relationship between two distinct [[Vertex]]. * * When used is a directed context, the edge goes _from_ `v1` _to_ `v2`. * @@ -11,11 +11,13 @@ import java.util.Objects * @param v2 * The second [[Vertex]]. */ -final class Edge( +final class Edge private ( val v1: Vertex, val v2: Vertex ): + def max: Vertex = if v1 >= v2 then v1 else v2 + /** When considering this edge as _directed_, this function returns the * [[Vertex]] that is the beginning of the connection. * @@ -54,11 +56,63 @@ object Edge: given CanEqual[Edge, Edge] = CanEqual.derived + /** Instantiate a new Edge. + * + * Throws an exception if the given vertices are the same. + * + * @param v1 + * The first [[Vertex]]. + * @param v2 + * The second [[Vertex]]. + * @return + * The new edge. + */ def apply( v1: Vertex, v2: Vertex - ): Edge = new Edge(v1, v2) + ): Edge = + if v1 != v2 then new Edge(v1, v2) + else + throw new IllegalArgumentException( + "Loop edges are not supported. Edges must refer to two distinct vertexes." + ) - def apply(vs: (Vertex, Vertex)): Edge = new Edge(vs._1, vs._2) + /** Instantiate a new Edge. + * + * Throws an exception if the given vertices are the same. + * + * @param vs + * The pair of [[Vertex]]. + * @return + * The new edge. + */ + def apply(vs: (Vertex, Vertex)): Edge = Edge(vs._1, vs._2) + + /** Instantiate a new Edge. + * + * Throws an exception if the given vertices are the same or are not valid + * [[Vertex]] values. + * + * @param v1 + * The first [[Vertex]]. + * @param v2 + * The second [[Vertex]]. + * @return + * The new edge. + */ + def apply( + v1: Int, + v2: Int + ): Edge = Edge(Vertex(v1), Vertex(v2)) + + /** Instantiate a list of Edge. + * + * @param edges + * The list of Edge. + * @return + * The captured list of Edge. + */ + def list(edges: (Int, Int)*): List[Edge] = + edges.map(e => apply(e._1, e._2)).toList end Edge 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 497f34d..9ea539f 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Graph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Graph.scala @@ -58,3 +58,41 @@ trait Graph: case _ => false end Graph + +object Graph: + + /** Construct a new [[UndirectedGraph]] from the given [[Edge]]. + * + * @param numberOfVertices + * The number of vertices in this [[Graph]]. + * @param edges + * The [[Edge]] in this graph. + * @return + * The new [[UndirectedGraph]]. + */ + def undirected( + numberOfVertices: Size, + edges: (Int, Int)* + ): UndirectedGraph = + new UndirectedGraph( + numberOfVertices, + Adjacency.fromEdges(numberOfVertices, Edge.list(edges*)) + ) + + /** Construct a new [[UndirectedGraph]] from the given [[Edge]]. + * + * @param edges + * The [[Edge]] in this graph. + * @return + * The new [[UndirectedGraph]]. + */ + def undirected( + edges: (Int, Int)* + ): UndirectedGraph = + val adj = Adjacency.fromEdges(Edge.list(edges*)) + new UndirectedGraph( + adj.numberOfVertices, + adj + ) + +end Graph diff --git a/modules/core/src/main/scala/gs/graph/v0/directed/SCC.scala b/modules/core/src/main/scala/gs/graph/v0/SCC.scala similarity index 54% rename from modules/core/src/main/scala/gs/graph/v0/directed/SCC.scala rename to modules/core/src/main/scala/gs/graph/v0/SCC.scala index ce46957..7e1b6b2 100644 --- a/modules/core/src/main/scala/gs/graph/v0/directed/SCC.scala +++ b/modules/core/src/main/scala/gs/graph/v0/SCC.scala @@ -1,4 +1,4 @@ -package gs.graph.v0.directed +package gs.graph.v0 import gs.graph.v0.Graph import gs.graph.v0.Vertex @@ -6,8 +6,51 @@ import scala.collection.mutable.ListBuffer import scala.collection.mutable.Stack /** Represents a _Strongly Connected Component_ (SCC). + * + * Each SCC is guaranteed to be non-empty. */ -opaque type SCC = Vector[Vertex] +final class SCC private (val value: Set[Vertex]): + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: SCC => 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 SCC. + */ + def size: Int = value.size + + /** @return + * True if this SCC contains a single vertex. + */ + def isSingle: Boolean = value.size == 1 + + /** @return + * The set of vertices in the SCC. + */ + def vertices: Set[Vertex] = value + + /** Retrieve an arbitrary SCC member as the root vertex. + */ + lazy val root: Vertex = value.toList.head + +end SCC object SCC: @@ -20,36 +63,54 @@ object SCC: * @return * The new SCC. */ - def apply(value: Seq[Vertex]): SCC = value.distinct.toVector + def apply(value: Vertex*): SCC = + if value.isEmpty then + throw new IllegalArgumentException( + "Empty strongly connected components (SCC) are not allowed." + ) + else new SCC(value.toSet) + + def of(value: Int*): SCC = + if value.isEmpty then + throw new IllegalArgumentException( + "Empty strongly connected components (SCC) are not allowed." + ) + else new SCC(value.map(Vertex.apply).toSet) + + /** Instantiate a new SCC that contains a single [[Vertex]]. + * + * @param value + * The input vertex. + * @return + * The new SCC. + */ + def single(value: Vertex): SCC = new SCC(Set(value)) given CanEqual[SCC, SCC] = CanEqual.derived - extension (scc: SCC) - /** @return - * The underlying value of this SCC. - */ - def unwrap(): Vector[Vertex] = scc - - /** @return - * The vertices contained by this SCC. - */ - def vertices: Vector[Vertex] = scc - - /** @return - * The number of vertices contained by this SCC. - */ - def size: Int = scc.size - - /** @return - * True if this SCC contains a single vertex. - */ - def isSingle: Boolean = scc.size == 1 - - /** Implementation of Tarjan's Algorithm for finding all strongly connected - * components for some directed graph. + /** Alias for `findAll`. + * + * Implementation of Tarjan's Algorithm for finding all strongly connected + * components for some graph. + * + * This algorithm captures directed cycles. Any vertex not part of some + * directed cycle (e.g. every node in a DAG) forms an SCC by itself. * * @param g - * The directed graph to analyze. + * The graph to analyze. + * @return + * The complete list of [[SCC]] for the input graph. + */ + def tarjan(g: Graph): List[SCC] = findAll(g) + + /** Implementation of Tarjan's Algorithm for finding all strongly connected + * components for some graph. + * + * This algorithm captures directed cycles. Any vertex not part of some + * directed cycle (e.g. every node in a DAG) forms an SCC by itself. + * + * @param g + * The graph to analyze. * @return * The complete list of [[SCC]] for the input graph. */ @@ -134,14 +195,22 @@ object SCC: sccVertex = s.pop() scc.addOne(sccVertex) - output.addOne(SCC(scc.toVector)) + if !scc.isEmpty then output.addOne(new SCC(scc.toSet)) + else println("ERROR") else () - final private class Counter(var i: Int = 0): + /** Mutable state wrapper around an integer. + * + * @param i + * The integer value. + */ + final private class Counter(var i: Int): def increment(): Unit = i += 1 def get(): Int = i + end Counter + end SCC diff --git a/modules/core/src/main/scala/gs/graph/v0/Size.scala b/modules/core/src/main/scala/gs/graph/v0/Size.scala index e98c192..77cee7f 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Size.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Size.scala @@ -83,17 +83,6 @@ object Size: if candidate >= 0 then new Size(candidate) else throw new IllegalArgumentException("Size values must be 0 or greater.") - given CanEqual[Size, Size] = CanEqual.derived - - given Ordering[Size] with - - /** @inheritDocs - */ - def compare( - x: Size, - y: Size - ): Int = x.value - y.value - /** Instantiate the size of some array. * * @param arr @@ -112,4 +101,24 @@ object Size: */ def fromVector(vec: Vector[?]): Size = new Size(vec.length) + /** Instantiate the size of any iterable. + * + * @param iter + * The iterable. + * @return + * Size representing the total length of the iterable. + */ + def of(iter: Iterable[?]): Size = new Size(iter.size) + + given CanEqual[Size, Size] = CanEqual.derived + + given Ordering[Size] with + + /** @inheritDocs + */ + def compare( + x: Size, + y: Size + ): Int = x.value - y.value + end Size 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 4795f5d..211d621 100644 --- a/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala @@ -17,8 +17,24 @@ class UndirectedGraph( /** @inheritDocs */ - override def selectRoots(): Vector[Vertex] = - if numberOfVertices == Size.Zero then Vector.empty else Vector(Vertex.Zero) + 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. + */ + lazy val roots: Vector[Vertex] = + calculateRoots() + + private def calculateRoots(): Vector[Vertex] = + if numberOfVertices == Size.Zero then Vector.empty + else oneRootFromEachSCC(calculateSCCs()).toVector + + private def calculateSCCs(): List[SCC] = + SCC.findAll(this) + + private def oneRootFromEachSCC(sccs: List[SCC]): List[Vertex] = + sccs.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 ff65a11..1e35add 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Vertex.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Vertex.scala @@ -8,6 +8,11 @@ package gs.graph.v0 */ final class Vertex private (val ordinal: Int) extends Ordered[Vertex]: + /** @return + * The value (ordinal) of the Vertex. + */ + def value: Int = ordinal + /** @inheritDocs */ override def compare(that: Vertex): Int = @@ -37,6 +42,16 @@ final class Vertex private (val ordinal: Int) extends Ordered[Vertex]: */ infix def <(value: Int): Boolean = ordinal < value + /** Is the ordinal of this vertex less than or equal to some integer value? + * + * @param value + * The integer value. + * @return + * True if the ordinal is less than or equal to the integer value. False + * otherwise. + */ + infix def <=(value: Int): Boolean = ordinal <= value + /** Is the ordinal of this vertex greater than some integer value? * * @param value @@ -46,6 +61,16 @@ final class Vertex private (val ordinal: Int) extends Ordered[Vertex]: */ infix def >(value: Int): Boolean = ordinal > value + /** Is the ordinal of this vertex greater than or equal to some integer value? + * + * @param value + * The integer value. + * @return + * True if the ordinal is greater than or equal to the integer value. False + * otherwise. + */ + infix def >=(value: Int): Boolean = ordinal >= value + /** Is the ordinal of this vertex less than some [[Size]] value? * * @param value @@ -55,6 +80,16 @@ final class Vertex private (val ordinal: Int) extends Ordered[Vertex]: */ infix def <(size: Size): Boolean = ordinal < size.value + /** Is the ordinal of this vertex less than or equal to some [[Size]] value? + * + * @param value + * The [[Size]] value. + * @return + * True if the ordinal is less than or equal to the [[Size]] value. False + * otherwise. + */ + infix def <=(size: Size): Boolean = ordinal <= size.value + /** Is the ordinal of this vertex greater than some [[Size]] value? * * @param value @@ -64,6 +99,17 @@ final class Vertex private (val ordinal: Int) extends Ordered[Vertex]: */ infix def >(size: Size): Boolean = ordinal > size.value + /** Is the ordinal of this vertex greater than or equal to some [[Size]] + * value? + * + * @param value + * The [[Size]] value. + * @return + * True if the ordinal is greater than or equal to the [[Size]] value. + * False otherwise. + */ + infix def >=(size: Size): Boolean = ordinal >= size.value + object Vertex: /** The fixed value 0 expressed as a [[Vertex]]. 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 7f6b158..4d75708 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 @@ -63,7 +63,7 @@ object Digraph: ): Digraph = new Digraph( numberOfVertices = numberOfVertices, - adjacency = Adjacency.fromDirectedEdges(numberOfVertices, edges), + adjacency = Adjacency.fromEdges(numberOfVertices, edges), roots = findRootsForDirectedEdges(numberOfVertices, edges) ) diff --git a/modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDigraph.scala b/modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDigraph.scala index 931ab45..111a3f6 100644 --- a/modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDigraph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDigraph.scala @@ -61,7 +61,7 @@ object SingleRootDigraph: if root < numberOfVertices then new SingleRootDigraph( numberOfVertices, - Adjacency.fromDirectedEdges(numberOfVertices, edges), + Adjacency.fromEdges(numberOfVertices, edges), root ) else throw GraphException.RootOutOfBounds(root, numberOfVertices) @@ -86,7 +86,7 @@ object SingleRootDigraph: Some( new SingleRootDigraph( numberOfVertices, - Adjacency.fromDirectedEdges(numberOfVertices, edges), + Adjacency.fromEdges(numberOfVertices, edges), roots(0) ) ) 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 2cf46a2..ad3af26 100644 --- a/modules/core/src/test/scala/gs/graph/v0/AdjacencyTests.scala +++ b/modules/core/src/test/scala/gs/graph/v0/AdjacencyTests.scala @@ -1,27 +1,91 @@ package gs.graph.v0 +import scala.util.Failure +import scala.util.Try + class AdjacencyTests extends munit.FunSuite: - test("should provide incoming connections") { - val N = Size(7) - val vs = (0 until N.value).map(Vertex(_)).toArray - val E = List( - Edge(vs(0), vs(1)), - Edge(vs(0), vs(2)), - Edge(vs(0), vs(3)), - Edge(vs(1), vs(4)), - Edge(vs(2), vs(4)), - Edge(vs(3), vs(4)), - Edge(vs(3), vs(5)), - Edge(vs(4), vs(6)) - ) - val A = Adjacency.fromDirectedEdges(N, E) + private val size = Size(7) + private val vs = (0 until size.value).map(Vertex(_)).toArray - assertEquals(A.incoming(vs(0)), Vector.empty) - assertEquals(A.incoming(vs(1)), Vector(vs(0))) - assertEquals(A.incoming(vs(2)), Vector(vs(0))) - assertEquals(A.incoming(vs(3)), Vector(vs(0))) - assertEquals(A.incoming(vs(4)), Vector(vs(1), vs(2), vs(3))) - assertEquals(A.incoming(vs(5)), Vector(vs(3))) - assertEquals(A.incoming(vs(6)), Vector(vs(4))) + private val edges = Edge.list( + 0 -> 1, + 0 -> 2, + 0 -> 3, + 1 -> 4, + 2 -> 4, + 3 -> 4, + 3 -> 5, + 4 -> 6 + ) + + private val adj = Adjacency.fromEdges(size, edges) + + test("should provide equality") { + val a2 = Adjacency(adj.neighbors) + val a3 = Adjacency.Single + + assertEquals(adj, a2) + assertNotEquals(adj, a3) + assert(!adj.equals(null)) + assert(!adj.equals("anything")) + } + + 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))) + } + + test("should show no incoming connections for a vertex not in the graph") { + assertEquals(adj.incoming(Vertex(999)), Vector.empty) + } + + test("should express an adjacency as edges") { + val es = adj.toEdges() + assertEquals(es, edges.toVector) + } + + test("should track the number of vertices in the graph") { + assertEquals(adj.numberOfVertices, size) + } + + test("should track the number of edges in the graph") { + assertEquals(adj.numberOfEdges, Size.of(edges)) + } + + test("should construct an adjacency with no edges") { + val sz = Size(10) + val a = Adjacency.noEdges(sz) + assertEquals(a.numberOfVertices, sz) + assertEquals(a.numberOfEdges, Size.Zero) + } + + test( + "should guard against out of bound values when building from edges with an explicit bound" + ) { + 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 _ => + fail( + "Expected adjacency construction to fail with an IllegalArgumentException." + ) + } + + test("should construct from edges, finding the largest known vertex") { + val expectedSize = Size(10) + val es = Edge.list(0 -> 1, 1 -> 2, 0 -> 9, 3 -> 1) + val a = Adjacency.fromEdges(es) + assertEquals(a.numberOfVertices, expectedSize) + assertEquals(a.numberOfEdges, Size.of(es)) } diff --git a/modules/core/src/test/scala/gs/graph/v0/EdgeTests.scala b/modules/core/src/test/scala/gs/graph/v0/EdgeTests.scala new file mode 100644 index 0000000..df18163 --- /dev/null +++ b/modules/core/src/test/scala/gs/graph/v0/EdgeTests.scala @@ -0,0 +1,36 @@ +package gs.graph.v0 + +import munit.* +import scala.util.Failure +import scala.util.Try + +class EdgeTests extends FunSuite: + + test("should represent an edge with two vertexes") { + val v1 = Vertex.Zero + val v2 = Vertex(1) + val v3 = Vertex(3) + val edge1 = Edge(v1, v2) + val edge2 = Edge(v1 -> v2) + val edge3 = Edge(v3, v1) + assertEquals(edge1, edge2) + assertNotEquals(edge1, edge3) + assertEquals(edge1.toString(), s"($v1, $v2)") + assertEquals(edge1.toString(), edge2.toString()) + assertEquals(edge1.hashCode(), edge2.hashCode()) + assertNotEquals(edge1.toString(), edge3.toString()) + assertNotEquals(edge1.hashCode(), edge3.hashCode()) + assertEquals(edge1.from, v1) + assertEquals(edge1.to, v2) + assertEquals(edge1.equals("unrelated-type"), false) + } + + test("should forbid loop edges") { + Try(Edge(1, 1)) match + case Failure(ex: IllegalArgumentException) => + assertEquals( + ex.getMessage(), + "Loop edges are not supported. Edges must refer to two distinct vertexes." + ) + case _ => fail("Expected to fail with an IllegalArgumentException") + } diff --git a/modules/core/src/test/scala/gs/graph/v0/SizeTests.scala b/modules/core/src/test/scala/gs/graph/v0/SizeTests.scala new file mode 100644 index 0000000..981ff85 --- /dev/null +++ b/modules/core/src/test/scala/gs/graph/v0/SizeTests.scala @@ -0,0 +1,53 @@ +package gs.graph.v0 + +import munit.* +import scala.util.Try + +class SizeTests extends FunSuite: + + test("should represent a vertex - some integer >= 0") { + val s1 = Size.Zero + val s2 = Size(0) + val s3 = Size(3) + assertEquals(s1, s2) + assertNotEquals(s1, s3) + assertEquals(s1.compare(s2), 0) + assertEquals(s1.compare(s3), -3) + assertEquals(s1.equals("unrelated-type"), false) + assertEquals(s1.hashCode(), s2.hashCode()) + assertNotEquals(s1.hashCode(), s3.hashCode()) + assertEquals(s1 < s3, true) + assertEquals(s1 < s2, false) + assertEquals(s1 > s3, false) + assertEquals(s3 > s1, true) + assertEquals(s1 < 3, true) + assertEquals(s1 < 0, false) + assertEquals(s1 > 0, false) + assertEquals(s3 > 0, true) + assertEquals(s1 < Vertex(3), true) + assertEquals(s1 < Vertex.Zero, false) + assertEquals(s1 > Vertex.Zero, false) + assertEquals(s3 > Vertex.Zero, true) + assertEquals(s1.toString(), s2.toString()) + assertNotEquals(s1.toString(), s3.toString()) + assertEquals(Size.Zero.value, 0) + assertEquals(Size.One.value, 1) + } + + test("should throw an exception if an invalid value is given") { + Try(Size(-1)).toEither.left.toOption match + case None => fail("Size instantiation should have failed.") + case Some(cause) => + assertEquals(cause.getMessage(), "Size values must be 0 or greater.") + } + + test("should instantiate from arrays based on the array length") { + val n1 = 3 + val n2 = 5 + val a1 = Array.fill(n1)(0) + val a2 = Array.fill(n2)(0) + val s1 = Size.fromArray(a1) + val s2 = Size.fromArray(a2) + assertEquals(s1.value, n1) + assertEquals(s2.value, n2) + } diff --git a/modules/core/src/test/scala/gs/graph/v0/TarjansTests.scala b/modules/core/src/test/scala/gs/graph/v0/TarjansTests.scala new file mode 100644 index 0000000..3387cdb --- /dev/null +++ b/modules/core/src/test/scala/gs/graph/v0/TarjansTests.scala @@ -0,0 +1,102 @@ +package gs.graph.v0 + +import gs.graph.v0.directed.Digraph +import munit.* + +class TarjansTests extends FunSuite: + + test("should provide basic SCC representation") { + 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) + + assertEquals(s1, s3) + assertNotEquals(s1, s2) + assertEquals(s2.value, Set(v0, v1, v2)) + assertEquals(s2.vertices, Set(v0, v1, v2)) + assertEquals(s2.size, 3) + assertEquals(s2.isSingle, false) + assertEquals(s1.isSingle, true) + } + + 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) + assertEquals(sccs, Nil) + assertEquals(alt, Nil) + } + + 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) + assertEquals(sccs, List(expected)) + } + + /** Each vertex of a DAG is an SCC. + */ + test("should identify a single SCC for a single 'line' of vertices") { + val n = Size(3) + + // 0 -> 1 -> 2 + val g = Digraph.fromAdjacency( + Adjacency.fromEdges( + n, + List( + Edge(0, 1), + Edge(1, 2) + ) + ) + ) + + val sccs = SCC.findAll(g) + assertEquals(g.roots, Vector(Vertex.Zero)) + assertEquals(sccs.toSet, Set(SCC.of(0), SCC.of(1), SCC.of(2))) + } + + test("should handle a graph with multiple SCCs") { + val n = Size(9) + + // Establish discrete cycles + // 0 -> 1 -> 2 -> 0 + // 3 -> 4 -> 5 -> 3 + // 6 -> 7 -> 8 -> 6 + val g = Digraph.fromAdjacency( + Adjacency.fromEdges( + n, + List( + // Cycle 1 + Edge(0, 1), + Edge(1, 2), + Edge(2, 0), + // Cycle 2 + Edge(3, 4), + Edge(4, 5), + Edge(5, 3), + // Cycle 3 + Edge(6, 7), + Edge(7, 8), + Edge(8, 6) + ) + ) + ) + + val sccs = SCC.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) + ) + ) + } + +end TarjansTests diff --git a/modules/core/src/test/scala/gs/graph/v0/UndirectedGraphTests.scala b/modules/core/src/test/scala/gs/graph/v0/UndirectedGraphTests.scala new file mode 100644 index 0000000..dda6fdb --- /dev/null +++ b/modules/core/src/test/scala/gs/graph/v0/UndirectedGraphTests.scala @@ -0,0 +1,19 @@ +package gs.graph.v0 + +import munit.* + +class UndirectedGraphTests extends FunSuite: + + test("should support an empty graph") { + val g = UndirectedGraph.Empty + assertEquals(g.numberOfVertices, Size.Zero) + assertEquals(g.adjacency, Adjacency.Empty) + assertEquals(g.disposition, GraphDisposition.Undirected) + assertEquals(g.selectRoots(), Vector.empty) + } + + test( + "should calculate roots based on an arbitrary vertex from each connected component" + ) { + // TODO: implement - current logic is wrong. + } diff --git a/modules/core/src/test/scala/gs/graph/v0/VertexTests.scala b/modules/core/src/test/scala/gs/graph/v0/VertexTests.scala new file mode 100644 index 0000000..305d2d0 --- /dev/null +++ b/modules/core/src/test/scala/gs/graph/v0/VertexTests.scala @@ -0,0 +1,40 @@ +package gs.graph.v0 + +import munit.* +import scala.util.Try + +class VertexTests extends FunSuite: + + test("should represent a vertex - some integer >= 0") { + val v1 = Vertex.Zero + val v2 = Vertex(0) + val v3 = Vertex(3) + assertEquals(v1, v2) + assertNotEquals(v1, v3) + assertEquals(v1.compare(v2), 0) + assertEquals(v1.compare(v3), -3) + assertEquals(v1.equals("unrelated-type"), false) + assertEquals(v1.hashCode(), v2.hashCode()) + assertNotEquals(v1.hashCode(), v3.hashCode()) + assertEquals(v1 < v3, true) + assertEquals(v1 < v2, false) + assertEquals(v1 > v3, false) + assertEquals(v3 > v1, true) + assertEquals(v1 < 3, true) + assertEquals(v1 < 0, false) + assertEquals(v1 > 0, false) + assertEquals(v3 > 0, true) + assertEquals(v1 < Size(3), true) + assertEquals(v1 < Size.Zero, false) + assertEquals(v1 > Size.Zero, false) + assertEquals(v3 > Size.Zero, true) + assertEquals(v1.toString(), v2.toString()) + assertNotEquals(v1.toString(), v3.toString()) + } + + test("should throw an exception if an invalid value is given") { + Try(Vertex(-1)).toEither.left.toOption match + case None => fail("Vertex instantiation should have failed.") + case Some(cause) => + assertEquals(cause.getMessage(), "Vertex values must be 0 or greater.") + } 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 40cd965..b9b6570 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 @@ -3,7 +3,6 @@ package gs.graph.v0.directed import gs.graph.v0.Adjacency import gs.graph.v0.Edge import gs.graph.v0.Size -import gs.graph.v0.Vertex import munit.* class DagTests extends FunSuite: @@ -23,20 +22,19 @@ class DagTests extends FunSuite: test("should validate a single-root graph") { val size = Size(8) - val vs = (0 until size.value).map(Vertex(_)) val digraph: Digraph = Digraph.fromAdjacency( - Adjacency.fromDirectedEdges( + Adjacency.fromEdges( numberOfVertices = size, - edges = Seq( - Edge(vs(0) -> vs(1)), - Edge(vs(0) -> vs(2)), - Edge(vs(0) -> vs(3)), - Edge(vs(1) -> vs(4)), - Edge(vs(2) -> vs(4)), - Edge(vs(3) -> vs(5)), - Edge(vs(4) -> vs(6)), - Edge(vs(5) -> vs(6)), - Edge(vs(6) -> vs(7)) + edges = Edge.list( + 0 -> 1, + 0 -> 2, + 0 -> 3, + 1 -> 4, + 2 -> 4, + 3 -> 5, + 4 -> 6, + 5 -> 6, + 6 -> 7 ) ) ) @@ -48,15 +46,14 @@ class DagTests extends FunSuite: test("should NOT validate a single-root digraph with a cycle") { val size = Size(4) - val vs = (0 until size.value).map(Vertex(_)) val digraph: Digraph = Digraph.fromAdjacency( - Adjacency.fromDirectedEdges( + Adjacency.fromEdges( numberOfVertices = size, - edges = Seq( - Edge(vs(0) -> vs(1)), - Edge(vs(1) -> vs(2)), - Edge(vs(2) -> vs(3)), - Edge(vs(3) -> vs(1)) + edges = Edge.list( + 0 -> 1, + 1 -> 2, + 2 -> 3, + 3 -> 1 ) ) ) diff --git a/project/build.properties b/project/build.properties index 08a6fc0..dabdb15 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.12.8 +sbt.version=1.12.11