From 59cea1cbf6adb364b1c8216d2f53f71db42248ee Mon Sep 17 00:00:00 2001 From: spavikevik Date: Thu, 12 Dec 2024 17:04:52 +0900 Subject: [PATCH 1/6] add difference counts --- .../main/scala/difflicious/DiffResult.scala | 35 ++++++++-- .../scala/difflicious/differ/MapDiffer.scala | 69 ++++++++++--------- .../difflicious/differ/RecordDiffer.scala | 52 +++++++------- .../scala/difflicious/differ/SeqDiffer.scala | 11 ++- .../scala/difflicious/differ/SetDiffer.scala | 12 +++- .../internal/SumCountsSyntax.scala | 10 +++ 6 files changed, 122 insertions(+), 67 deletions(-) create mode 100644 modules/core/src/main/scala/difflicious/internal/SumCountsSyntax.scala diff --git a/modules/core/src/main/scala/difflicious/DiffResult.scala b/modules/core/src/main/scala/difflicious/DiffResult.scala index 7c58950..014172a 100644 --- a/modules/core/src/main/scala/difflicious/DiffResult.scala +++ b/modules/core/src/main/scala/difflicious/DiffResult.scala @@ -6,24 +6,31 @@ import scala.collection.immutable.ListMap sealed trait DiffResult { - /** - * Whether this DiffResult was produced from an ignored Differ + /** Whether this DiffResult was produced from an ignored Differ * @return */ def isIgnored: Boolean - /** - * Whether this DiffResult is consider "successful". - * If there are any non-ignored differences found, then this should be false + /** Whether this DiffResult is consider "successful". If there are any non-ignored differences found, then this should + * be false * @return */ def isOk: Boolean - /** - * Whether the input leading to this DiffResult has both sides or just one. + /** Whether the input leading to this DiffResult has both sides or just one. * @return */ def pairType: PairType + + /** The number of differences found, regardless of if they were ignored or not + * @return + */ + def differenceCount: Int + + /** The number of ignored differences + * @return + */ + def ignoredCount: Int } object DiffResult { @@ -33,6 +40,8 @@ object DiffResult { pairType: PairType, isIgnored: Boolean, isOk: Boolean, + differenceCount: Int, + ignoredCount: Int, ) extends DiffResult final case class RecordResult( @@ -41,6 +50,8 @@ object DiffResult { pairType: PairType, isIgnored: Boolean, isOk: Boolean, + differenceCount: Int, + ignoredCount: Int, ) extends DiffResult final case class MapResult( @@ -49,6 +60,8 @@ object DiffResult { pairType: PairType, isIgnored: Boolean, isOk: Boolean, + differenceCount: Int, + ignoredCount: Int, ) extends DiffResult object MapResult { @@ -64,6 +77,8 @@ object DiffResult { isIgnored: Boolean, ) extends DiffResult { override def isOk: Boolean = isIgnored + override def differenceCount: Int = 1 + override def ignoredCount: Int = 1 } sealed trait ValueResult extends DiffResult @@ -72,14 +87,20 @@ object DiffResult { final case class Both(obtained: String, expected: String, isSame: Boolean, isIgnored: Boolean) extends ValueResult { override def pairType: PairType = PairType.Both override def isOk: Boolean = isIgnored || isSame + override def differenceCount: Int = if (isSame) 0 else 1 + override def ignoredCount: Int = if (!isSame && isIgnored) 1 else 0 } final case class ObtainedOnly(obtained: String, isIgnored: Boolean) extends ValueResult { override def pairType: PairType = PairType.ObtainedOnly override def isOk: Boolean = false + override def differenceCount: Int = 1 + override def ignoredCount: Int = if (isIgnored) 1 else 0 } final case class ExpectedOnly(expected: String, isIgnored: Boolean) extends ValueResult { override def pairType: PairType = PairType.ExpectedOnly override def isOk: Boolean = false + override def differenceCount: Int = 1 + override def ignoredCount: Int = if (isIgnored) 1 else 0 } } diff --git a/modules/core/src/main/scala/difflicious/differ/MapDiffer.scala b/modules/core/src/main/scala/difflicious/differ/MapDiffer.scala index 83d4f35..2be6e53 100644 --- a/modules/core/src/main/scala/difflicious/differ/MapDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/MapDiffer.scala @@ -1,12 +1,13 @@ package difflicious.differ -import difflicious.DiffResult.{ValueResult, MapResult} +import difflicious.DiffResult.{MapResult, ValueResult} import scala.collection.mutable import difflicious.ConfigureOp.PairBy import difflicious.differ.MapDiffer.mapKeyToString +import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName -import difflicious.{Differ, DiffResult, ConfigureOp, ConfigureError, ConfigurePath, DiffInput, PairType} +import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, DiffResult, Differ, PairType} import difflicious.utils.MapLike class MapDiffer[M[_, _], K, V]( @@ -23,60 +24,64 @@ class MapDiffer[M[_, _], K, V]( val obtainedOnly = mutable.ArrayBuffer.empty[MapResult.Entry] val both = mutable.ArrayBuffer.empty[MapResult.Entry] val expectedOnly = mutable.ArrayBuffer.empty[MapResult.Entry] - obtained.foreach { - case (k, actualV) => - expected.get(k) match { - case Some(expectedV) => - both += MapResult.Entry( - mapKeyToString(k, keyDiffer), - valueDiffer.diff(actualV, expectedV), - ) - case None => - obtainedOnly += MapResult.Entry( - mapKeyToString(k, keyDiffer), - valueDiffer.diff(DiffInput.ObtainedOnly(actualV)), - ) - } - } - expected.foreach { - case (k, expectedV) => - if (obtained.contains(k)) { - // Do nothing, already compared when iterating through obtained - } else { - expectedOnly += MapResult.Entry( + obtained.foreach { case (k, actualV) => + expected.get(k) match { + case Some(expectedV) => + both += MapResult.Entry( + mapKeyToString(k, keyDiffer), + valueDiffer.diff(actualV, expectedV), + ) + case None => + obtainedOnly += MapResult.Entry( mapKeyToString(k, keyDiffer), - valueDiffer.diff(DiffInput.ExpectedOnly(expectedV)), + valueDiffer.diff(DiffInput.ObtainedOnly(actualV)), ) - } + } } + expected.foreach { case (k, expectedV) => + if (obtained.contains(k)) { + // Do nothing, already compared when iterating through obtained + } else { + expectedOnly += MapResult.Entry( + mapKeyToString(k, keyDiffer), + valueDiffer.diff(DiffInput.ExpectedOnly(expectedV)), + ) + } + } + + val bothValues = both.map(_.value) MapResult( typeName = typeName, (obtainedOnly ++ both ++ expectedOnly).toVector, PairType.Both, isIgnored = isIgnored, - isOk = isIgnored || obtainedOnly.isEmpty && expectedOnly.isEmpty && both.forall(_.value.isOk), + isOk = isIgnored || obtainedOnly.isEmpty && expectedOnly.isEmpty && bothValues.forall(_.isOk), + differenceCount = bothValues.differenceCount, + ignoredCount = bothValues.ignoredCount, ) case DiffInput.ObtainedOnly(obtained) => DiffResult.MapResult( typeName = typeName, - entries = obtained.map { - case (k, v) => - MapResult.Entry(mapKeyToString(k, keyDiffer), valueDiffer.diff(DiffInput.ObtainedOnly(v))) + entries = obtained.map { case (k, v) => + MapResult.Entry(mapKeyToString(k, keyDiffer), valueDiffer.diff(DiffInput.ObtainedOnly(v))) }.toVector, pairType = PairType.ObtainedOnly, isIgnored = isIgnored, isOk = isIgnored, + differenceCount = obtained.size, + ignoredCount = if (isIgnored) obtained.size else 0, ) case DiffInput.ExpectedOnly(expected) => DiffResult.MapResult( typeName = typeName, - entries = expected.map { - case (k, v) => - MapResult.Entry(mapKeyToString(k, keyDiffer), valueDiffer.diff(DiffInput.ExpectedOnly(v))) + entries = expected.map { case (k, v) => + MapResult.Entry(mapKeyToString(k, keyDiffer), valueDiffer.diff(DiffInput.ExpectedOnly(v))) }.toVector, pairType = PairType.ExpectedOnly, isIgnored = isIgnored, isOk = isIgnored, + differenceCount = expected.size, + ignoredCount = if (isIgnored) expected.size else 0, ) } diff --git a/modules/core/src/main/scala/difflicious/differ/RecordDiffer.scala b/modules/core/src/main/scala/difflicious/differ/RecordDiffer.scala index f87a37f..d9e679c 100644 --- a/modules/core/src/main/scala/difflicious/differ/RecordDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/RecordDiffer.scala @@ -2,10 +2,10 @@ package difflicious.differ import scala.collection.immutable.ListMap import difflicious._ +import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName -/** - * A differ for a record-like data structure such as tuple or case classes. +/** A differ for a record-like data structure such as tuple or case classes. */ final class RecordDiffer[T]( fieldDiffers: ListMap[String, (T => Any, Differ[Any])], @@ -17,29 +17,31 @@ final class RecordDiffer[T]( override def diff(inputs: DiffInput[T]): R = inputs match { case DiffInput.Both(obtained, expected) => { val diffResults = fieldDiffers - .map { - case (fieldName, (getter, differ)) => - val diffResult = differ.diff(getter(obtained), getter(expected)) + .map { case (fieldName, (getter, differ)) => + val diffResult = differ.diff(getter(obtained), getter(expected)) - fieldName -> diffResult + fieldName -> diffResult } .to(ListMap) + + val diffResultValues = diffResults.values DiffResult .RecordResult( typeName = typeName, fields = diffResults, pairType = PairType.Both, isIgnored = isIgnored, - isOk = isIgnored || diffResults.values.forall(_.isOk), + isOk = isIgnored || diffResultValues.forall(_.isOk), + differenceCount = diffResultValues.differenceCount, + ignoredCount = diffResultValues.ignoredCount, ) } case DiffInput.ObtainedOnly(value) => { val diffResults = fieldDiffers - .map { - case (fieldName, (getter, differ)) => - val diffResult = differ.diff(DiffInput.ObtainedOnly(getter(value))) + .map { case (fieldName, (getter, differ)) => + val diffResult = differ.diff(DiffInput.ObtainedOnly(getter(value))) - fieldName -> diffResult + fieldName -> diffResult } .to(ListMap) DiffResult @@ -49,15 +51,16 @@ final class RecordDiffer[T]( pairType = PairType.ObtainedOnly, isIgnored = isIgnored, isOk = isIgnored, + differenceCount = diffResults.values.size, + ignoredCount = if (isIgnored) diffResults.values.size else 0, ) } case DiffInput.ExpectedOnly(expected) => { val diffResults = fieldDiffers - .map { - case (fieldName, (getter, differ)) => - val diffResult = differ.diff(DiffInput.ExpectedOnly(getter(expected))) + .map { case (fieldName, (getter, differ)) => + val diffResult = differ.diff(DiffInput.ExpectedOnly(getter(expected))) - fieldName -> diffResult + fieldName -> diffResult } .to(ListMap) DiffResult @@ -67,6 +70,8 @@ final class RecordDiffer[T]( pairType = PairType.ExpectedOnly, isIgnored = isIgnored, isOk = isIgnored, + differenceCount = diffResults.values.size, + ignoredCount = if (isIgnored) diffResults.values.size else 0, ) } } @@ -82,15 +87,14 @@ final class RecordDiffer[T]( fieldDiffers .get(step) .toRight(ConfigureError.NonExistentField(nextPath, "RecordDiffer")) - .flatMap { - case (getter, fieldDiffer) => - fieldDiffer.configureRaw(nextPath, op).map { newFieldDiffer => - new RecordDiffer[T]( - fieldDiffers = fieldDiffers.updated(step, (getter, newFieldDiffer)), - isIgnored = isIgnored, - typeName = typeName, - ) - } + .flatMap { case (getter, fieldDiffer) => + fieldDiffer.configureRaw(nextPath, op).map { newFieldDiffer => + new RecordDiffer[T]( + fieldDiffers = fieldDiffers.updated(step, (getter, newFieldDiffer)), + isIgnored = isIgnored, + typeName = typeName, + ) + } } override def configurePairBy(path: ConfigurePath, op: ConfigureOp.PairBy[_]): Either[ConfigureError, Differ[T]] = Left(ConfigureError.InvalidConfigureOp(path, op, "RecordDiffer")) diff --git a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala index 618b165..86b37b9 100644 --- a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala @@ -3,8 +3,9 @@ package difflicious.differ import difflicious.DiffResult.ListResult import difflicious.utils.SeqLike import difflicious.ConfigureOp.PairBy -import difflicious.{Differ, DiffResult, ConfigureOp, ConfigureError, ConfigurePath, DiffInput, PairType} +import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, DiffResult, Differ, PairType} import SeqDiffer.diffPairByFunc +import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName import scala.collection.mutable @@ -45,6 +46,8 @@ final class SeqDiffer[F[_], A]( pairType = PairType.Both, isIgnored = isIgnored, isOk = isIgnored || diffResults.forall(_.isOk), + differenceCount = diffResults.differenceCount, + ignoredCount = diffResults.ignoredCount, ) } case PairBy.ByFunc(func) => { @@ -55,6 +58,8 @@ final class SeqDiffer[F[_], A]( pairType = PairType.Both, isIgnored = isIgnored, isOk = isIgnored || allIsOk, + differenceCount = results.differenceCount, + ignoredCount = results.ignoredCount, ) } } @@ -68,6 +73,8 @@ final class SeqDiffer[F[_], A]( pairType = PairType.ObtainedOnly, isIgnored = isIgnored, isOk = isIgnored, + differenceCount = actual.size, + ignoredCount = if (isIgnored) actual.size else 0, ) case DiffInput.ExpectedOnly(expected) => ListResult( @@ -78,6 +85,8 @@ final class SeqDiffer[F[_], A]( pairType = PairType.ExpectedOnly, isIgnored = isIgnored, isOk = isIgnored, + differenceCount = expected.size, + ignoredCount = if (isIgnored) expected.size else 0, ) } diff --git a/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala b/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala index 6f27c8a..43dccaf 100644 --- a/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala @@ -3,9 +3,10 @@ package difflicious.differ import difflicious.ConfigureOp.PairBy import difflicious.DiffResult.ListResult import difflicious.differ.SeqDiffer.diffPairByFunc +import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName import difflicious.utils.SetLike -import difflicious.{Differ, ConfigureOp, ConfigureError, ConfigurePath, DiffInput, PairType} +import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, Differ, PairType} final class SetDiffer[F[_], A]( isIgnored: Boolean, @@ -26,6 +27,8 @@ final class SetDiffer[F[_], A]( PairType.ObtainedOnly, isIgnored = isIgnored, isOk = isIgnored, + differenceCount = actual.size, + ignoredCount = if (isIgnored) actual.size else 0, ) case DiffInput.ExpectedOnly(expected) => ListResult( @@ -36,8 +39,10 @@ final class SetDiffer[F[_], A]( pairType = PairType.ExpectedOnly, isIgnored = isIgnored, isOk = isIgnored, + differenceCount = expected.size, + ignoredCount = if (isIgnored) expected.size else 0, ) - case DiffInput.Both(obtained, expected) => { + case DiffInput.Both(obtained, expected) => val (results, overallIsSame) = diffPairByFunc(obtained.toSeq, expected.toSeq, matchFunc, itemDiffer) ListResult( typeName = typeName, @@ -45,8 +50,9 @@ final class SetDiffer[F[_], A]( pairType = PairType.Both, isIgnored = isIgnored, isOk = isIgnored || overallIsSame, + differenceCount = results.differenceCount, + ignoredCount = results.ignoredCount, ) - } } override def configureIgnored(newIgnored: Boolean): Differ[F[A]] = diff --git a/modules/core/src/main/scala/difflicious/internal/SumCountsSyntax.scala b/modules/core/src/main/scala/difflicious/internal/SumCountsSyntax.scala new file mode 100644 index 0000000..f59b0f1 --- /dev/null +++ b/modules/core/src/main/scala/difflicious/internal/SumCountsSyntax.scala @@ -0,0 +1,10 @@ +package difflicious.internal + +import difflicious.DiffResult + +private[difflicious] object SumCountsSyntax { + implicit class DiffResultIterableOps(iterable: Iterable[DiffResult]) { + def differenceCount: Int = iterable.foldLeft(0) { (acc, next) => acc + next.differenceCount } + def ignoredCount: Int = iterable.foldLeft(0) { (acc, next) => acc + next.ignoredCount } + } +} From 83dd90b83a286c35ebd15298281e9bdc2f1896e5 Mon Sep 17 00:00:00 2001 From: spavikevik Date: Thu, 12 Dec 2024 17:05:10 +0900 Subject: [PATCH 2/6] implement intelligent diffing --- .../main/scala/difflicious/PairingFn.scala | 38 +++++++++++++++ .../scala/difflicious/differ/SeqDiffer.scala | 41 ++++++++-------- .../scala/difflicious/differ/SetDiffer.scala | 8 ++-- .../test/scala/difflicious/DifferSpec.scala | 48 +++++++++++++++++++ 4 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 modules/core/src/main/scala/difflicious/PairingFn.scala diff --git a/modules/core/src/main/scala/difflicious/PairingFn.scala b/modules/core/src/main/scala/difflicious/PairingFn.scala new file mode 100644 index 0000000..6798bb6 --- /dev/null +++ b/modules/core/src/main/scala/difflicious/PairingFn.scala @@ -0,0 +1,38 @@ +package difflicious + +sealed trait PairingFn[A, B] { + def fn: A => B + def matching(a1: A, a2: A)(differ: Differ[A]): Boolean +} + +object PairingFn { + def lift[A, B](fn: A => B): PairingFn[A, B] = UsingEquals(fn) + + def approximate[A](threshold: Int): PairingFn[A, A] = + Approximate(identity, differenceCountThreshold = threshold) + + case class UsingEquals[A, B](fn: A => B) extends PairingFn[A, B] { + override def matching(a1: A, a2: A)(differ: Differ[A]): Boolean = fn(a1) == fn(a2) + } + + sealed trait DifferBased[A, B] extends PairingFn[A, B] { + def fn: A => B + def differenceCountThreshold: Int + } + + case class Approximate[A](fn: A => A, differenceCountThreshold: Int) extends DifferBased[A, A] { + override def matching(a1: A, a2: A)(differ: Differ[A]): Boolean = { + val diffResult = differ.diff(fn(a1), fn(a2)) + + diffResult.differenceCount - diffResult.ignoredCount <= differenceCountThreshold + } + } + + case class Custom[A, B](fn: A => B, differenceCountThreshold: Int, pairDiffer: Differ[B]) extends DifferBased[A, B] { + override def matching(a1: A, a2: A)(differ: Differ[A]): Boolean = { + val diffResult = pairDiffer.diff(fn(a1), fn(a2)) + + diffResult.differenceCount - diffResult.ignoredCount <= differenceCountThreshold + } + } +} diff --git a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala index 86b37b9..96f6797 100644 --- a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala @@ -3,7 +3,7 @@ package difflicious.differ import difflicious.DiffResult.ListResult import difflicious.utils.SeqLike import difflicious.ConfigureOp.PairBy -import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, DiffResult, Differ, PairType} +import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, DiffResult, Differ, PairType, PairingFn} import SeqDiffer.diffPairByFunc import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName @@ -51,7 +51,7 @@ final class SeqDiffer[F[_], A]( ) } case PairBy.ByFunc(func) => { - val (results, allIsOk) = diffPairByFunc(actual, expected, func, itemDiffer) + val (results, allIsOk) = diffPairByFunc(actual, expected, PairingFn.lift(func), itemDiffer) ListResult( typeName = typeName, items = results, @@ -157,10 +157,10 @@ object SeqDiffer { // Given two lists of item, find "matching" items using te provided function // (where "matching" means ==). For example we might want to items by // person name. - private[difflicious] def diffPairByFunc[A]( + private[difflicious] def diffPairByFunc[A, B]( obtained: Seq[A], expected: Seq[A], - func: A => Any, + func: PairingFn[A, B], itemDiffer: Differ[A], ): (Vector[DiffResult], Boolean) = { val matchedIndexes = mutable.BitSet.empty @@ -168,18 +168,16 @@ object SeqDiffer { val expWithIdx = expected.zipWithIndex var allIsOk = true obtained.foreach { a => - val aMatchVal = func(a) - val found = expWithIdx.find { - case (e, idx) => - if (!matchedIndexes.contains(idx) && aMatchVal == func(e)) { - val res = itemDiffer.diff(a, e) - results += res - matchedIndexes += idx - allIsOk &= res.isOk - true - } else { - false - } + val found = expWithIdx.find { case (e, idx) => + if (!matchedIndexes.contains(idx) && func.matching(a, e)(itemDiffer)) { + val res = itemDiffer.diff(a, e) + results += res + matchedIndexes += idx + allIsOk &= res.isOk + true + } else { + false + } } if (found.isEmpty) { @@ -188,12 +186,11 @@ object SeqDiffer { } } - expWithIdx.foreach { - case (e, idx) => - if (!matchedIndexes.contains(idx)) { - results += itemDiffer.diff(DiffInput.ExpectedOnly(e)) - allIsOk = false - } + expWithIdx.foreach { case (e, idx) => + if (!matchedIndexes.contains(idx)) { + results += itemDiffer.diff(DiffInput.ExpectedOnly(e)) + allIsOk = false + } } (results.toVector, allIsOk) diff --git a/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala b/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala index 43dccaf..d5fa4e3 100644 --- a/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala @@ -6,12 +6,12 @@ import difflicious.differ.SeqDiffer.diffPairByFunc import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName import difflicious.utils.SetLike -import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, Differ, PairType} +import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, Differ, PairType, PairingFn} final class SetDiffer[F[_], A]( isIgnored: Boolean, itemDiffer: Differ[A], - matchFunc: A => Any, + matchFunc: PairingFn[A, Any], typeName: SomeTypeName, asSet: SetLike[F], ) extends Differ[F[A]] { @@ -89,7 +89,7 @@ final class SetDiffer[F[_], A]( new SetDiffer[F, A]( isIgnored = isIgnored, itemDiffer = itemDiffer, - matchFunc = m.func.asInstanceOf[A => Any], + matchFunc = PairingFn.lift(m.func.asInstanceOf[A => Any]), typeName = typeName, asSet = asSet, ), @@ -105,7 +105,7 @@ object SetDiffer { ): SetDiffer[F, A] = new SetDiffer[F, A]( isIgnored = false, itemDiffer, - matchFunc = identity, + matchFunc = PairingFn.approximate(threshold = 1).asInstanceOf[PairingFn[A, Any]], typeName = typeName, asSet = asSet, ) diff --git a/modules/coretest/src/test/scala/difflicious/DifferSpec.scala b/modules/coretest/src/test/scala/difflicious/DifferSpec.scala index ea1a597..0fab3bd 100644 --- a/modules/coretest/src/test/scala/difflicious/DifferSpec.scala +++ b/modules/coretest/src/test/scala/difflicious/DifferSpec.scala @@ -544,6 +544,54 @@ class DifferSpec extends ScalaCheckSuite with ScalaVersionDependentTests { ) } + test("Set: intelligently match minimally-different entries") { + assertConsoleDiffOutput( + Differ + .setDiffer[Set, CC] + .configureRaw(ConfigurePath.of("each", "dd"), ConfigureOp.ignore) + .unsafeGet, + Set( + CC(1, "s1", 3), + CC(2, "s2", 2), + CC(4, "s2", 2), + CC(5, "s8", 2), + ), + Set( + CC(1, "s1", 1), + CC(2, "s2", 2), + CC(3, "s2", 2), + CC(3, "s3", 8), + ), + s"""Set( + | CC( + | i: 1, + | s: "s1", + | dd: $grayIgnoredStr + | ), + | CC( + | i: 2, + | s: "s2", + | dd: $grayIgnoredStr + | ), + | CC( + | i: ${R}4$X -> ${G}3$X, + | s: "s2", + | dd: $grayIgnoredStr + | ), + | ${R}CC( + | i: 5, + | s: "s8", + | dd: $justIgnoredStr + | )$X, + | ${G}CC( + | i: 3, + | s: "s3", + | dd: $justIgnoredStr + | )$X + |)""".stripMargin, + ) + } + test("Set: When only 'obtained' is provided when diffing") { assertConsoleDiffOutput( Differ[List[Set[Int]]], From 5bb71791624f4285add63987a4c45abf1e578c01 Mon Sep 17 00:00:00 2001 From: spavikevik Date: Thu, 12 Dec 2024 23:36:06 +0900 Subject: [PATCH 3/6] refactor Approximate pairing function to accept DiffResult directly --- .../main/scala/difflicious/PairingFn.scala | 38 ------------------- .../scala/difflicious/PairingFunction.scala | 34 +++++++++++++++++ .../scala/difflicious/differ/SeqDiffer.scala | 16 ++++---- .../scala/difflicious/differ/SetDiffer.scala | 8 ++-- 4 files changed, 46 insertions(+), 50 deletions(-) delete mode 100644 modules/core/src/main/scala/difflicious/PairingFn.scala create mode 100644 modules/core/src/main/scala/difflicious/PairingFunction.scala diff --git a/modules/core/src/main/scala/difflicious/PairingFn.scala b/modules/core/src/main/scala/difflicious/PairingFn.scala deleted file mode 100644 index 6798bb6..0000000 --- a/modules/core/src/main/scala/difflicious/PairingFn.scala +++ /dev/null @@ -1,38 +0,0 @@ -package difflicious - -sealed trait PairingFn[A, B] { - def fn: A => B - def matching(a1: A, a2: A)(differ: Differ[A]): Boolean -} - -object PairingFn { - def lift[A, B](fn: A => B): PairingFn[A, B] = UsingEquals(fn) - - def approximate[A](threshold: Int): PairingFn[A, A] = - Approximate(identity, differenceCountThreshold = threshold) - - case class UsingEquals[A, B](fn: A => B) extends PairingFn[A, B] { - override def matching(a1: A, a2: A)(differ: Differ[A]): Boolean = fn(a1) == fn(a2) - } - - sealed trait DifferBased[A, B] extends PairingFn[A, B] { - def fn: A => B - def differenceCountThreshold: Int - } - - case class Approximate[A](fn: A => A, differenceCountThreshold: Int) extends DifferBased[A, A] { - override def matching(a1: A, a2: A)(differ: Differ[A]): Boolean = { - val diffResult = differ.diff(fn(a1), fn(a2)) - - diffResult.differenceCount - diffResult.ignoredCount <= differenceCountThreshold - } - } - - case class Custom[A, B](fn: A => B, differenceCountThreshold: Int, pairDiffer: Differ[B]) extends DifferBased[A, B] { - override def matching(a1: A, a2: A)(differ: Differ[A]): Boolean = { - val diffResult = pairDiffer.diff(fn(a1), fn(a2)) - - diffResult.differenceCount - diffResult.ignoredCount <= differenceCountThreshold - } - } -} diff --git a/modules/core/src/main/scala/difflicious/PairingFunction.scala b/modules/core/src/main/scala/difflicious/PairingFunction.scala new file mode 100644 index 0000000..a5ece8b --- /dev/null +++ b/modules/core/src/main/scala/difflicious/PairingFunction.scala @@ -0,0 +1,34 @@ +package difflicious + +sealed trait PairingFunction[A, B] { + def matching(a1: A, a2: A)(diffResult: Differ[A]#R): Boolean +} + +object PairingFunction { + def lift[A, B](fn: A => B): PairingFunction[A, B] = UsingEquals(fn) + + def approximate[A](threshold: Int): PairingFunction[A, A] = + Approximate(differenceCountThreshold = threshold) + + case class UsingEquals[A, B](fn: A => B) extends PairingFunction[A, B] { + override def matching(a1: A, a2: A)(diffResult: Differ[A]#R): Boolean = fn(a1) == fn(a2) + } + + sealed trait DifferBased[A, B] extends PairingFunction[A, B] { + def differenceCountThreshold: Int + } + + case class Approximate[A](differenceCountThreshold: Int) extends DifferBased[A, A] { + override def matching(a1: A, a2: A)(diffResult: Differ[A]#R): Boolean = + diffResult.differenceCount - diffResult.ignoredCount <= differenceCountThreshold + + } + + case class Custom[A, B](fn: A => B, differenceCountThreshold: Int, pairDiffer: Differ[B]) extends DifferBased[A, B] { + override def matching(a1: A, a2: A)(diffResult: Differ[A]#R): Boolean = { + val diffResult = pairDiffer.diff(fn(a1), fn(a2)) + + diffResult.differenceCount - diffResult.ignoredCount <= differenceCountThreshold + } + } +} diff --git a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala index 96f6797..0497231 100644 --- a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala @@ -3,7 +3,7 @@ package difflicious.differ import difflicious.DiffResult.ListResult import difflicious.utils.SeqLike import difflicious.ConfigureOp.PairBy -import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, DiffResult, Differ, PairType, PairingFn} +import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, DiffResult, Differ, PairType, PairingFunction} import SeqDiffer.diffPairByFunc import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName @@ -51,7 +51,7 @@ final class SeqDiffer[F[_], A]( ) } case PairBy.ByFunc(func) => { - val (results, allIsOk) = diffPairByFunc(actual, expected, PairingFn.lift(func), itemDiffer) + val (results, allIsOk) = diffPairByFunc(actual, expected, PairingFunction.lift(func), itemDiffer) ListResult( typeName = typeName, items = results, @@ -158,10 +158,10 @@ object SeqDiffer { // (where "matching" means ==). For example we might want to items by // person name. private[difflicious] def diffPairByFunc[A, B]( - obtained: Seq[A], - expected: Seq[A], - func: PairingFn[A, B], - itemDiffer: Differ[A], + obtained: Seq[A], + expected: Seq[A], + func: PairingFunction[A, B], + itemDiffer: Differ[A], ): (Vector[DiffResult], Boolean) = { val matchedIndexes = mutable.BitSet.empty val results = mutable.ArrayBuffer.empty[DiffResult] @@ -169,8 +169,8 @@ object SeqDiffer { var allIsOk = true obtained.foreach { a => val found = expWithIdx.find { case (e, idx) => - if (!matchedIndexes.contains(idx) && func.matching(a, e)(itemDiffer)) { - val res = itemDiffer.diff(a, e) + val res = itemDiffer.diff(a, e) + if (!matchedIndexes.contains(idx) && func.matching(a, e)(res)) { results += res matchedIndexes += idx allIsOk &= res.isOk diff --git a/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala b/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala index d5fa4e3..f551c6a 100644 --- a/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala @@ -6,12 +6,12 @@ import difflicious.differ.SeqDiffer.diffPairByFunc import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName import difflicious.utils.SetLike -import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, Differ, PairType, PairingFn} +import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, Differ, PairType, PairingFunction} final class SetDiffer[F[_], A]( isIgnored: Boolean, itemDiffer: Differ[A], - matchFunc: PairingFn[A, Any], + matchFunc: PairingFunction[A, Any], typeName: SomeTypeName, asSet: SetLike[F], ) extends Differ[F[A]] { @@ -89,7 +89,7 @@ final class SetDiffer[F[_], A]( new SetDiffer[F, A]( isIgnored = isIgnored, itemDiffer = itemDiffer, - matchFunc = PairingFn.lift(m.func.asInstanceOf[A => Any]), + matchFunc = PairingFunction.lift(m.func.asInstanceOf[A => Any]), typeName = typeName, asSet = asSet, ), @@ -105,7 +105,7 @@ object SetDiffer { ): SetDiffer[F, A] = new SetDiffer[F, A]( isIgnored = false, itemDiffer, - matchFunc = PairingFn.approximate(threshold = 1).asInstanceOf[PairingFn[A, Any]], + matchFunc = PairingFunction.approximate(threshold = 1).asInstanceOf[PairingFunction[A, Any]], typeName = typeName, asSet = asSet, ) From 7e4b8aba6bf58b1a32e3388e6baeb3fb3ce7b99b Mon Sep 17 00:00:00 2001 From: spavikevik Date: Thu, 12 Dec 2024 23:39:07 +0900 Subject: [PATCH 4/6] reformat SeqDiffer.scala --- .../scala/difflicious/differ/SeqDiffer.scala | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala index 0497231..08f554d 100644 --- a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala @@ -3,7 +3,16 @@ package difflicious.differ import difflicious.DiffResult.ListResult import difflicious.utils.SeqLike import difflicious.ConfigureOp.PairBy -import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, DiffResult, Differ, PairType, PairingFunction} +import difflicious.{ + ConfigureError, + ConfigureOp, + ConfigurePath, + DiffInput, + DiffResult, + Differ, + PairType, + PairingFunction, +} import SeqDiffer.diffPairByFunc import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName @@ -158,10 +167,10 @@ object SeqDiffer { // (where "matching" means ==). For example we might want to items by // person name. private[difflicious] def diffPairByFunc[A, B]( - obtained: Seq[A], - expected: Seq[A], - func: PairingFunction[A, B], - itemDiffer: Differ[A], + obtained: Seq[A], + expected: Seq[A], + func: PairingFunction[A, B], + itemDiffer: Differ[A], ): (Vector[DiffResult], Boolean) = { val matchedIndexes = mutable.BitSet.empty val results = mutable.ArrayBuffer.empty[DiffResult] From 70c7bb121566ea13f86c27ec53454f8adcc912c2 Mon Sep 17 00:00:00 2001 From: spavikevik Date: Fri, 13 Dec 2024 12:51:49 +0900 Subject: [PATCH 5/6] address feedback --- .../scala/difflicious/PairingFunction.scala | 16 ++++++-------- .../scala/difflicious/differ/SeqDiffer.scala | 22 ++++++++++++++----- .../scala/difflicious/differ/SetDiffer.scala | 2 +- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/modules/core/src/main/scala/difflicious/PairingFunction.scala b/modules/core/src/main/scala/difflicious/PairingFunction.scala index a5ece8b..110cce1 100644 --- a/modules/core/src/main/scala/difflicious/PairingFunction.scala +++ b/modules/core/src/main/scala/difflicious/PairingFunction.scala @@ -1,31 +1,29 @@ package difflicious -sealed trait PairingFunction[A, B] { - def matching(a1: A, a2: A)(diffResult: Differ[A]#R): Boolean -} +sealed trait PairingFunction[A, B] object PairingFunction { def lift[A, B](fn: A => B): PairingFunction[A, B] = UsingEquals(fn) - def approximate[A](threshold: Int): PairingFunction[A, A] = - Approximate(differenceCountThreshold = threshold) + def approximative[A](threshold: Int): PairingFunction[A, A] = + Approximative(differenceCountThreshold = threshold) case class UsingEquals[A, B](fn: A => B) extends PairingFunction[A, B] { - override def matching(a1: A, a2: A)(diffResult: Differ[A]#R): Boolean = fn(a1) == fn(a2) + def matching(a1: A, a2: A): Boolean = fn(a1) == fn(a2) } sealed trait DifferBased[A, B] extends PairingFunction[A, B] { def differenceCountThreshold: Int } - case class Approximate[A](differenceCountThreshold: Int) extends DifferBased[A, A] { - override def matching(a1: A, a2: A)(diffResult: Differ[A]#R): Boolean = + case class Approximative[A](differenceCountThreshold: Int) extends DifferBased[A, A] { + def matching(a1: A, a2: A)(diffResult: Differ[A]#R): Boolean = diffResult.differenceCount - diffResult.ignoredCount <= differenceCountThreshold } case class Custom[A, B](fn: A => B, differenceCountThreshold: Int, pairDiffer: Differ[B]) extends DifferBased[A, B] { - override def matching(a1: A, a2: A)(diffResult: Differ[A]#R): Boolean = { + def matching(a1: A, a2: A): Boolean = { val diffResult = pairDiffer.diff(fn(a1), fn(a2)) diffResult.differenceCount - diffResult.ignoredCount <= differenceCountThreshold diff --git a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala index 08f554d..8ed4768 100644 --- a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala @@ -14,6 +14,7 @@ import difflicious.{ PairingFunction, } import SeqDiffer.diffPairByFunc +import difflicious.PairingFunction.{Approximative, Custom} import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName @@ -178,14 +179,23 @@ object SeqDiffer { var allIsOk = true obtained.foreach { a => val found = expWithIdx.find { case (e, idx) => - val res = itemDiffer.diff(a, e) - if (!matchedIndexes.contains(idx) && func.matching(a, e)(res)) { - results += res + def pushResult(res: => itemDiffer.R): Boolean = { + val memoizedRes = res + results += memoizedRes matchedIndexes += idx - allIsOk &= res.isOk + allIsOk &= memoizedRes.isOk + true - } else { - false + } + + func match { + case fn: PairingFunction.UsingEquals[_, _] => + !matchedIndexes.contains(idx) && fn.matching(a, e) && pushResult(itemDiffer.diff(a, e)) + case fn: Approximative[A] => + val diffRes = itemDiffer.diff(a, e) + !matchedIndexes.contains(idx) && fn.matching(a, e)(diffRes) && pushResult(diffRes) + case fn: Custom[A, B] => + !matchedIndexes.contains(idx) && fn.matching(a, e) && pushResult(itemDiffer.diff(a, e)) } } diff --git a/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala b/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala index f551c6a..6cadaf7 100644 --- a/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala @@ -105,7 +105,7 @@ object SetDiffer { ): SetDiffer[F, A] = new SetDiffer[F, A]( isIgnored = false, itemDiffer, - matchFunc = PairingFunction.approximate(threshold = 1).asInstanceOf[PairingFunction[A, Any]], + matchFunc = PairingFunction.approximative(threshold = 1).asInstanceOf[PairingFunction[A, Any]], typeName = typeName, asSet = asSet, ) From b5ec1de30a54c6d5ff07b78850eb2f22b46289b0 Mon Sep 17 00:00:00 2001 From: spavikevik Date: Fri, 13 Dec 2024 15:11:52 +0900 Subject: [PATCH 6/6] refactor --- modules/core/src/main/scala/difflicious/PairingFunction.scala | 2 +- modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/core/src/main/scala/difflicious/PairingFunction.scala b/modules/core/src/main/scala/difflicious/PairingFunction.scala index 110cce1..3c662cc 100644 --- a/modules/core/src/main/scala/difflicious/PairingFunction.scala +++ b/modules/core/src/main/scala/difflicious/PairingFunction.scala @@ -17,7 +17,7 @@ object PairingFunction { } case class Approximative[A](differenceCountThreshold: Int) extends DifferBased[A, A] { - def matching(a1: A, a2: A)(diffResult: Differ[A]#R): Boolean = + def matching(diffResult: Differ[A]#R): Boolean = diffResult.differenceCount - diffResult.ignoredCount <= differenceCountThreshold } diff --git a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala index 8ed4768..8f84bb6 100644 --- a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala @@ -193,7 +193,7 @@ object SeqDiffer { !matchedIndexes.contains(idx) && fn.matching(a, e) && pushResult(itemDiffer.diff(a, e)) case fn: Approximative[A] => val diffRes = itemDiffer.diff(a, e) - !matchedIndexes.contains(idx) && fn.matching(a, e)(diffRes) && pushResult(diffRes) + !matchedIndexes.contains(idx) && fn.matching(diffRes) && pushResult(diffRes) case fn: Custom[A, B] => !matchedIndexes.contains(idx) && fn.matching(a, e) && pushResult(itemDiffer.diff(a, e)) }