From 23e85ec2414643f8abf585c09ca092b7cc4a797c Mon Sep 17 00:00:00 2001 From: bjornregnell Date: Wed, 10 Apr 2024 20:33:50 +0200 Subject: [PATCH] more work on parsing paths and constraints, refactor, rename --- src/main/scala/00-zero-dep-extensions.scala | 89 ----------- src/main/scala/00-zero-dep-utils.scala | 154 ++++++++++++++++++++ src/main/scala/01-api-exports.scala | 2 +- src/main/scala/02-meta-model.scala | 34 +++++ src/main/scala/04-ModelMembers.scala | 6 +- src/main/scala/04-Path.scala | 98 +++++++++---- src/main/scala/04-Show.scala | 5 +- src/main/scala/05-constraints.scala | 82 +++++++++++ src/main/scala/06-examples.scala | 3 + src/test/scala/TestModelOps.scala | 15 +- 10 files changed, 359 insertions(+), 129 deletions(-) delete mode 100644 src/main/scala/00-zero-dep-extensions.scala create mode 100644 src/main/scala/00-zero-dep-utils.scala diff --git a/src/main/scala/00-zero-dep-extensions.scala b/src/main/scala/00-zero-dep-extensions.scala deleted file mode 100644 index 629c3f7..0000000 --- a/src/main/scala/00-zero-dep-extensions.scala +++ /dev/null @@ -1,89 +0,0 @@ -package reqt - -object StringExtensions: - extension (s: String) - def p: Unit = println(s) - - def toLines: Array[String] = s.split("\n") - def toWords: Array[String] = s.split(" ").map(_.trim).filter(_.nonEmpty) - - def firstWord: String = s.takeWhile(_.isLetter) - - def deCapitalize: String = s.take(1).toLowerCase ++ s.drop(1) - - def skipIndent: String = s.dropWhile(ch => ch.isSpaceChar || ch == '\t') - def skipFirstWord: String = s.dropWhile(ch => !(ch.isSpaceChar || ch == '\t')) - def skipFirstToken: String = s.skipIndent.skipFirstWord.trim - - def level(base: Int): Int = - val initSpace = s.takeWhile(ch => ch.isSpaceChar || ch == '\t') - initSpace.replace("\\t", " ").length + base - - def wrapLongLineAtWords(n: Int): String = - val words = s.split(" ").iterator - val sb = StringBuilder() - var i = 0 - while words.hasNext do - val w: String = words.next - i += w.length - if i > n then - if w.length > n then - sb.append(w) - sb.append('\n') - i = 0 - else - sb.append('\n') - sb.append(w) - i = w.length - else sb.append(w) - if words.hasNext then sb.append(' ') - end while - sb.toString - - def wrap(n: Int): String = s.split("\n").map(_.wrapLongLineAtWords(n)).mkString("\n") - - def editDistanceTo(t: String): Int = - //https://github.com/scala/scala/blob/4e03eb5a1c7dc2cb5274a453dbff38fef12f12f4/src/compiler/scala/tools/nsc/util/EditDistance.scala#L26 - val insertCost: Int = 1 - val deleteCost: Int = 1 - val subCost: Int = 1 - val matchCost: Int = 0 - val caseCost: Int = 1 - val transpositions: Boolean = false - val n = s.length - val m = t.length - if (n == 0) return m - if (m == 0) return n - - val d = Array.ofDim[Int](n + 1, m + 1) - 0 to n foreach (x => d(x)(0) = x) - 0 to m foreach (x => d(0)(x) = x) - - for - i <- 1 to n - s_i = s(i - 1) - j <- 1 to m - do - val t_j = t(j - 1) - val cost = - if s_i == t_j then matchCost - else if s_i.toLower == t_j.toLower then caseCost - else subCost - - val c1 = d(i - 1)(j) + deleteCost - val c2 = d(i)(j - 1) + insertCost - val c3 = d(i - 1)(j - 1) + cost - - d(i)(j) = c1 min c2 min c3 - - if transpositions then - if i > 1 && j > 1 && s(i - 1) == t(j - 2) && s(i - 2) == t(j - 1) then - d(i)(j) = d(i)(j) min (d(i - 2)(j - 2) + cost) - end if - - end for - - d(n)(m) - end editDistanceTo - - end extension diff --git a/src/main/scala/00-zero-dep-utils.scala b/src/main/scala/00-zero-dep-utils.scala new file mode 100644 index 0000000..b662591 --- /dev/null +++ b/src/main/scala/00-zero-dep-utils.scala @@ -0,0 +1,154 @@ +package reqt + +object StringExtensions: + extension (s: String) + def p: Unit = println(s) + + def toLines: Array[String] = s.split("\n") + def toWords: Array[String] = s.split(" ").map(_.trim).filter(_.nonEmpty) + + def firstWord: String = s.takeWhile(_.isLetter) + + def deCapitalize: String = s.take(1).toLowerCase ++ s.drop(1) + + def skipIndent: String = s.dropWhile(ch => ch.isSpaceChar || ch == '\t') + def skipFirstWord: String = s.dropWhile(ch => !(ch.isSpaceChar || ch == '\t')) + def skipFirstToken: String = s.skipIndent.skipFirstWord.trim + + def dropQuotes: String = + s.stripPrefix("\"").stripPrefix("\"\"").stripSuffix("\"").stripSuffix("\"\"") + + def splitEscaped(c: Char, esc: Char): Array[String] = + if s.isEmpty then Array(s) else + val result = collection.mutable.ArrayBuffer.empty[String] + var prev = 0 + var i = 0 + var isInsideEscape = false + while i < s.length do + if s(i) == esc then + isInsideEscape = !isInsideEscape + else if s(i) == c && !isInsideEscape then + result.append(s.substring(prev, i)) + prev = i + 1 + i += 1 + result.append(s.substring(prev, i)) + result.toArray + + def partitionByCharEscaped(c: Char, esc: Char): (String, String) = + if s.isEmpty then ("", "") else + var i = 0 + var isInsideEscape = false + var continue = true + while continue && i < s.length do + if s(i) == esc then + isInsideEscape = !isInsideEscape + i += 1 + else if s(i) == c && !isInsideEscape then + continue = false + else + i += 1 + (s.substring(0, i), s.substring(i, s.length)) + + + def level(base: Int): Int = + val initSpace = s.takeWhile(ch => ch.isSpaceChar || ch == '\t') + initSpace.replace("\\t", " ").length + base + + def wrapLongLineAtWords(n: Int): String = + val words = s.split(" ").iterator + val sb = StringBuilder() + var i = 0 + while words.hasNext do + val w: String = words.next + i += w.length + if i > n then + if w.length > n then + sb.append(w) + sb.append('\n') + i = 0 + else + sb.append('\n') + sb.append(w) + i = w.length + else sb.append(w) + if words.hasNext then sb.append(' ') + end while + sb.toString + + def wrap(n: Int): String = s.split("\n").map(_.wrapLongLineAtWords(n)).mkString("\n") + + def editDistanceTo(t: String): Int = + //https://github.com/scala/scala/blob/4e03eb5a1c7dc2cb5274a453dbff38fef12f12f4/src/compiler/scala/tools/nsc/util/EditDistance.scala#L26 + val insertCost: Int = 1 + val deleteCost: Int = 1 + val subCost: Int = 1 + val matchCost: Int = 0 + val caseCost: Int = 1 + val transpositions: Boolean = false + val n = s.length + val m = t.length + if (n == 0) return m + if (m == 0) return n + + val d = Array.ofDim[Int](n + 1, m + 1) + 0 to n foreach (x => d(x)(0) = x) + 0 to m foreach (x => d(0)(x) = x) + + for + i <- 1 to n + s_i = s(i - 1) + j <- 1 to m + do + val t_j = t(j - 1) + val cost = + if s_i == t_j then matchCost + else if s_i.toLower == t_j.toLower then caseCost + else subCost + + val c1 = d(i - 1)(j) + deleteCost + val c2 = d(i)(j - 1) + insertCost + val c3 = d(i - 1)(j - 1) + cost + + d(i)(j) = c1 min c2 min c3 + + if transpositions then + if i > 1 && j > 1 && s(i - 1) == t(j - 2) && s(i - 2) == t(j - 1) then + d(i)(j) = d(i)(j) min (d(i - 2)(j - 2) + cost) + end if + + end for + + d(n)(m) + end editDistanceTo + + end extension +end StringExtensions + +object err: + class ParseException(msg: String) extends Exception(msg) + def unknown(s: String) = ParseException(s"Unknown constraint: $s") + def missingPar(s: String) = ParseException(s"Missing enclosing () in: $s") + def missingEndPar(s: String) = ParseException(s"Missing matching ) at end: $s") + def badIdentifier(s: String) = ParseException(s"Bad identifier: $s") + def varExpected(s: String) = ParseException(s"Var expected: $s") + def identExpected(s: String) = ParseException(s"Identifier expected: $s") + def operatorExpected(s: String) = ParseException(s"Operator expected: $s") + def unknownTrailing(s: String) = ParseException(s"Unknown trailing chars: $s") + def illegalPath(s: String) = ParseException(s"Illegal path: $s") + +object parseUtils: + def isIdStart(s: String): Boolean = s.nonEmpty && s(0).isUnicodeIdentifierStart + + def parseInside(s: String, open: Char = '(', close: Char = ')', esc: Char = '"'): (String, String) = + if !s.trim.startsWith("(") then throw err.missingPar(s) + var level = 1 + var i = 1 + var isInsideEscape = false + while i < s.length && (level >= 1 || isInsideEscape) do + if s(i) == open then level += 1 + else if s(i) == close then level -= 1 + else if s(i) == esc then isInsideEscape = !isInsideEscape + i += 1 + if s(i - 1) != ')' then throw err.missingEndPar(s) + (s.substring(1,i - 1), s.substring(i)) + diff --git a/src/main/scala/01-api-exports.scala b/src/main/scala/01-api-exports.scala index 2fffb14..c398aed 100644 --- a/src/main/scala/01-api-exports.scala +++ b/src/main/scala/01-api-exports.scala @@ -7,7 +7,7 @@ export Model.{toModel, concatAdjacent} // extension methods on Seq[Elem] export Show.show // extension for pretty Model using enum types and apply export Selection.* // and/or-expressions for selecting Model parts -export Path.* // path factories for slash notation on Model +export Path.`/` // path factories for slash notation on Model export MarkdownParser.m // string interpolator to parse markdown Model export MarkdownParser.toModel // string extension to parse markdown Model diff --git a/src/main/scala/02-meta-model.scala b/src/main/scala/02-meta-model.scala index 9116933..945ed7a 100644 --- a/src/main/scala/02-meta-model.scala +++ b/src/main/scala/02-meta-model.scala @@ -192,6 +192,40 @@ object meta: def isRelType: Boolean = relTypes.isDefinedAt(s) def isElemStart: Boolean = isConceptName(s.skipIndent.takeWhile(ch => ch.isLetter)) //!(ch.isSpaceChar || ch == '\t'))) + def parseConcept(s: String): (Option[Elem | ElemType | Link], String) = + val trimmed = s.trim + val fw = trimmed.firstWord + val rest1 = trimmed.drop(fw.length).trim + if rest1.isEmpty then + if fw.isNodeType then (Some(nodeTypes(fw)), "") + else if fw.isRelType then (Some(relTypes(fw)), "") + else (None, s) + else if rest1(0) != '(' then (None, s) else + val (param, rest2) = rest1.partitionByCharEscaped(')','"') + val rest2afterParen = rest2.stripPrefix(")").trim + val hasLinkDot = rest2afterParen.startsWith(".") + val inner = param.trim.drop(1) + val unquoted = inner.dropQuotes + if fw == "Undefined" then + if inner.isStrAttrType then (Some(Undefined(strAttrTypes(inner))), rest2afterParen) + if inner.isIntAttrType then (Some(Undefined(intAttrTypes(inner))), rest2afterParen) + else (None, s) + else if fw.isEntType then + if !hasLinkDot then + (Some(entTypes(fw).apply(unquoted)), rest2afterParen) + else + val relPart = rest2afterParen.drop(1) + val relWord = relPart.firstWord + val rest3 = relPart.drop(relWord.length) + if relWord.isRelType then (Some(Link(entTypes(fw).apply(unquoted), relTypes(relWord))), rest3) + else (None, s) + else if fw.isStrAttrType then (Some(strAttrTypes(fw).apply(unquoted)), rest2afterParen) + else if fw.isIntAttrType then + val intOpt = inner.toIntOption + if intOpt.isDefined then (Some(intAttrTypes(fw).apply(intOpt.get)), rest2afterParen) + else (None, s) + else (None, s) + def matrix: Seq[Seq[String]] = for Concept(n, d, t, g) <- concepts yield Seq(n, t, g, d) def csv(delim: String = ";"): String = diff --git a/src/main/scala/04-ModelMembers.scala b/src/main/scala/04-ModelMembers.scala index 8cbcec6..b10ff7c 100644 --- a/src/main/scala/04-ModelMembers.scala +++ b/src/main/scala/04-ModelMembers.scala @@ -26,7 +26,7 @@ transparent trait ModelMembers: /** A new Model with other Model's elems appended to elems. Same as: `m :++ other` * NOTE: Different from `m ++ other` */ - def append(other: Model): Model = Model(elems ++ other.elems) + def append(other: Model): Model = Model(elems :++ other.elems) def :++(other: Model): Model = append(other) @@ -203,7 +203,7 @@ transparent trait ModelMembers: def top: Model = cut(1) def sub: Model = - elems.collect { case Rel(e, r, sub) => sub }.foldLeft(Model())(_ ++ _) + elems.collect { case Rel(e, r, sub) => sub }.foldLeft(Model())(_ :++ _) /** Cut all relations so that no relations is deeper than depth. cut(0) == tip, cut(1) == top **/ def cut(depth : Int): Model = @@ -223,7 +223,7 @@ transparent trait ModelMembers: case Vector(link) => val ms: Vector[Model] = elems.collect{ case r: Rel if r.e == link.e && r.t == link.t => r.sub} - ms.foldLeft(Model())(_ ++ _) + ms.foldLeft(Model())(_ :++ _) case Vector(link, rest*) => val m2 = self / link diff --git a/src/main/scala/04-Path.scala b/src/main/scala/04-Path.scala index 7b524a1..39ca183 100644 --- a/src/main/scala/04-Path.scala +++ b/src/main/scala/04-Path.scala @@ -1,5 +1,7 @@ package reqt +import reqt.meta.isElemStart + sealed trait Path: type Value @@ -31,42 +33,51 @@ sealed trait Path: val h = links.head Model() + Rel(h.e, h.t, firstLinkDropped.toModel) -case object Path: - def fromString(s: String): Path = ??? - // TODO: parse path strings such as """Path/Feature("x")/Undefined(Prio)""" - - final case class AttrTypePath[T](links: Vector[Link], dest: AttrType[T]) extends Path: - type Value = T - def hasDest: Boolean = true - def firstLinkDropped: AttrTypePath[T] = copy(links = links.tail) - - final case class AttrPath[T](links: Vector[Link], dest: Attr[T]) extends Path: - type Value = T - def hasDest: Boolean = true - def firstLinkDropped: AttrPath[T] = copy(links = links.tail) - - final case class EntTypePath(links: Vector[Link], dest: EntType) extends Path: - type Value = Nothing - def hasDest: Boolean = true - def firstLinkDropped: EntTypePath = copy(links = links.tail) - - final case class EntPath(links: Vector[Link], dest: Ent) extends Path: - type Value = Nothing - def hasDest: Boolean = true - def firstLinkDropped: EntPath = copy(links = links.tail) - - final case class LinkPath(links: Vector[Link]) extends Path: - type Value = Nothing - def dest = throw java.util.NoSuchElementException() - def hasDest: Boolean = false - def firstLinkDropped: LinkPath = copy(links = links.tail) - +case object Root: // TODO: is this needed or is it just another unnecessary way of doing Path(...) def /(link: Link): LinkPath = LinkPath(Vector(link)) def /[T](a: Attr[T]): AttrPath[T] = AttrPath[T](Vector(), a) def /[T](a: AttrType[T]): AttrTypePath[T] = AttrTypePath[T](Vector(), a) def /(e: Ent): EntPath = EntPath(Vector(), e) def /(e: EntType): EntTypePath = EntTypePath(Vector(), e) +case object Path: + def fromString(s: String): Option[Path] = + if s.isEmpty then None + else if s == "Path" || s == "Path()" then Some(Path()) + else if s.startsWith("(") && s.endsWith(")") then fromString(s.drop(1).dropRight(1)) + else if s.startsWith("Path(") && s.endsWith(")") then fromString(s.drop(5).dropRight(1)) + else + import parseUtils.* + import meta.* + val parts = s.splitEscaped('/','"').toVector + if parts.isEmpty then None else + type ParsedUnion = Elem | ElemType | Link + val parsed: Vector[(Option[ParsedUnion], String)] = parts.map(meta.parseConcept) + val parsedParts: Vector[ParsedUnion] = parsed.collect{ case (Some(p), s) if s.isEmpty => p} + if parsedParts.length != parts.length then None // overflow strings means malformed path + else + val links: Vector[Link] = parsed.collect{ case (Some(Link(e,t)), s) => Link(e,t)} + val last: ParsedUnion = parsedParts.last + if links != parsedParts.dropRight(1) then None // malformed path if not starting with links + else + last match + case _: Link => Some(LinkPath(links)) + case a: Attr[?] => Some(AttrPath(links, a)) + case a: AttrType[?] => Some(AttrTypePath(links, a)) + case e: Ent => Some(EntPath(links, e)) + case e: EntType => Some(EntTypePath(links, e)) + case _ => None // malformed path + + def apply(): LinkPath = LinkPath(Vector()) // Empty path + + def apply(p: Path): Path = p // recursively unpack Path(Path(Path(...))) + + def apply(link: Link): LinkPath = LinkPath(Vector(link)) + def apply[T](a: Attr[T]): AttrPath[T] = AttrPath[T](Vector(), a) + def apply[T](a: AttrType[T]): AttrTypePath[T] = AttrTypePath[T](Vector(), a) + def apply(e: Ent): EntPath = EntPath(Vector(), e) + def apply(e: EntType): EntTypePath = EntTypePath(Vector(), e) + extension (l1: Link) def /(l2: Link): LinkPath = LinkPath(Vector(l1, l2)) def /[T](a: Attr[T]): AttrPath[T] = AttrPath[T](Vector(l1), a) @@ -80,5 +91,30 @@ case object Path: def /[T](a: AttrType[T]): AttrTypePath[T] = AttrTypePath[T](lp.links, a) def /(e: Ent): EntPath = EntPath(lp.links, e) def /(e: EntType): EntTypePath = EntTypePath(lp.links, e) +end Path + +final case class AttrTypePath[T](links: Vector[Link], dest: AttrType[T]) extends Path: + type Value = T + def hasDest: Boolean = true + def firstLinkDropped: AttrTypePath[T] = copy(links = links.tail) + +final case class AttrPath[T](links: Vector[Link], dest: Attr[T]) extends Path: + type Value = T + def hasDest: Boolean = true + def firstLinkDropped: AttrPath[T] = copy(links = links.tail) + +final case class EntTypePath(links: Vector[Link], dest: EntType) extends Path: + type Value = Nothing + def hasDest: Boolean = true + def firstLinkDropped: EntTypePath = copy(links = links.tail) + +final case class EntPath(links: Vector[Link], dest: Ent) extends Path: + type Value = Nothing + def hasDest: Boolean = true + def firstLinkDropped: EntPath = copy(links = links.tail) - \ No newline at end of file +final case class LinkPath(links: Vector[Link]) extends Path: + type Value = Nothing + def dest = throw java.util.NoSuchElementException() + def hasDest: Boolean = false + def firstLinkDropped: LinkPath = copy(links = links.tail) \ No newline at end of file diff --git a/src/main/scala/04-Show.scala b/src/main/scala/04-Show.scala index d650400..8a61eaf 100644 --- a/src/main/scala/04-Show.scala +++ b/src/main/scala/04-Show.scala @@ -29,9 +29,6 @@ object Show: case n: Node => n.show case l: Link => l.show - // given showPathEmpty: Show[Path.Empty.type] with - // override def show(e: Path.Empty.type): String = "Path.Empty" - given showEmptyPath: Show[Path.type] with override def show(p: Path.type): String = "Path" @@ -46,7 +43,7 @@ object Show: case at: AttrType[?] => at.show case et: EntType => et.show val xs = if p.hasDest then (showLinks :+ showDest) else showLinks - xs.mkString("Path/","/", "") + xs.mkString("Path(","/", ")") given showElem: Show[Elem] with override def show(e: Elem): String = e match diff --git a/src/main/scala/05-constraints.scala b/src/main/scala/05-constraints.scala index d2083ce..7e45314 100644 --- a/src/main/scala/05-constraints.scala +++ b/src/main/scala/05-constraints.scala @@ -1,5 +1,7 @@ package reqt +import scala.compiletime.ops.double + /** A Scala-embedded DSL for expressing integer constraint satisfaction problems. */ object csp: def constraints(cs: Constr*): Seq[Constr] = cs.toSeq @@ -248,3 +250,83 @@ object csp: lazy val seq1 = item lazy val seq2 = load lazy val constSeq1 = size + + object parseConstraints: + import parseUtils.* + object mk: + type ConstrMaker = Seq[Var] => Constr + + val constr: Map[String, ConstrMaker] = Map( + "XeqY" -> (xs => XeqY(xs(0), xs(1))) + ) + + val oper: Map[String, ConstrMaker] = Map( + ">" -> (xs => XgtY(xs(0), xs(1))), + "<" -> (xs => XltY(xs(0), xs(1))), + ) + end mk + + val isConstrClass: Set[String] = mk.constr.keySet + + val isOperand: Set[String] = mk.oper.keySet + + def parseIdent(s: String): (String, String) = + val fw = s.firstWord + val rest = s.stripPrefix(fw).trim + if fw == "Path" then + ??? // (Path.fromString(s), rest) + else + if fw.isEmpty || !fw(0).isUnicodeIdentifierStart || !fw.drop(1).forall(_.isUnicodeIdentifierPart) + then throw err.badIdentifier(fw) + (fw, rest) + + def parseVar(s: String): (Var, String) = + val (inside, rest) = parseInside(s.stripPrefix("Var")) + val id: (String | Path) = + if inside.startsWith("Path") then Path.fromString(inside).getOrElse(throw err.illegalPath(inside)) + else inside + (Var(id), rest) + + def parseVarList(s: String): Seq[Var] = + val xs = s.splitEscaped(',', '"') + ??? + + def parseOperator(s: String): (String, String) = + val op = s.takeWhile(!_.isWhitespace) + (op, s.stripPrefix(op).trim) + + def parseConstr(s: String): Constr = + val fw = s.firstWord + if isConstrClass(fw) then + val rest1 = s.stripPrefix(fw) + val (inside, rest2) = parseInside(rest1) + if rest2.nonEmpty then throw err.unknownTrailing(rest2) else + val vs = parseVarList(inside) + mk.constr(fw)(vs) + end if + else if fw == "Var" then + val (v1, rest1) = parseVar(s) + val (op, rest2) = parseOperator(rest1) + if !isOperand(op) then throw err.operatorExpected(s"$op $rest2") + val (v2, rest3) = parseVar(rest2) + if rest3.nonEmpty then throw err.unknownTrailing(rest3) + mk.oper(op)(Seq(v1, v2)) + else if fw == "Path" then + ??? // use Path.fromString + else if isIdStart(s) then + val (i1, rest1) = parseIdent(s) + val (op, rest2) = parseOperator(rest1) + if !isOperand(op) then throw err.operatorExpected(s"$op $rest2") + val (i2, rest3) = parseIdent(rest2) + if rest3.nonEmpty then throw err.unknownTrailing(rest3) + mk.oper(op)(Seq(Var(i1), Var(i2))) + else throw err.varExpected(s) + + def parseLines(s: String): util.Try[Seq[Constr]] = util.Try: + val nonEmptyTrimmedLines = s.toLines.map(_.trim).filter(_.nonEmpty) + nonEmptyTrimmedLines.map(parseConstr).toSeq + + def apply(s: String): (Seq[Constr], String) = parseLines(s) match + case scala.util.Failure(exception) => (Seq(), exception.getMessage) + case scala.util.Success(value) => (value, "") + diff --git a/src/main/scala/06-examples.scala b/src/main/scala/06-examples.scala index 85335a8..0e7da8d 100644 --- a/src/main/scala/06-examples.scala +++ b/src/main/scala/06-examples.scala @@ -1,6 +1,9 @@ package reqt package examples +object constraintProblems: + val releasePlanningSimple = ??? + /** Examples from "Software Requirements - Styles and techniques" by S. Lauesen (2002)". */ object Lauesen: val allExamples = diff --git a/src/test/scala/TestModelOps.scala b/src/test/scala/TestModelOps.scala index bec3391..2c38156 100644 --- a/src/test/scala/TestModelOps.scala +++ b/src/test/scala/TestModelOps.scala @@ -142,8 +142,21 @@ class TestModelOps extends munit.FunSuite: m.maximal == m.paths.map(_.toModel).reduceLeft(_ :++ _) assert: m.minimal.sorted == m.minimal.normal + + assert: + m.paths.map(_.show).map(Path.fromString).map(_.get) == m.paths - val randomModels = Seq.tabulate(10)(i => Model.random(i)) + val ms = Seq.tabulate(100)(i => Model.random(10)) + + assert: + ms.forall(m => m.paths.map(_.show).map(Path.fromString).map(_.get) == m.paths) + + assert: + ms.forall(m => m.minimal.maximal.normal == m.maximal.minimal.normal) + + assert: + ms.forall(m => m.minimal.maximal.normal == m.maximal.minimal.normal) + // assert: // randomModels.forall(m => m.distinct == m.paths.map(_.toModel).foldLeft(Model())(_ ++ _)) ????