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/PairingFunction.scala b/modules/core/src/main/scala/difflicious/PairingFunction.scala new file mode 100644 index 0000000..3c662cc --- /dev/null +++ b/modules/core/src/main/scala/difflicious/PairingFunction.scala @@ -0,0 +1,32 @@ +package difflicious + +sealed trait PairingFunction[A, B] + +object PairingFunction { + def lift[A, B](fn: A => B): PairingFunction[A, B] = UsingEquals(fn) + + def approximative[A](threshold: Int): PairingFunction[A, A] = + Approximative(differenceCountThreshold = threshold) + + case class UsingEquals[A, B](fn: A => B) extends PairingFunction[A, B] { + 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 Approximative[A](differenceCountThreshold: Int) extends DifferBased[A, A] { + def matching(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] { + 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/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..8f84bb6 100644 --- a/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala @@ -3,8 +3,19 @@ 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, + PairingFunction, +} import SeqDiffer.diffPairByFunc +import difflicious.PairingFunction.{Approximative, Custom} +import difflicious.internal.SumCountsSyntax.DiffResultIterableOps import difflicious.utils.TypeName.SomeTypeName import scala.collection.mutable @@ -45,16 +56,20 @@ 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) => { - val (results, allIsOk) = diffPairByFunc(actual, expected, func, itemDiffer) + val (results, allIsOk) = diffPairByFunc(actual, expected, PairingFunction.lift(func), itemDiffer) ListResult( typeName = typeName, items = results, pairType = PairType.Both, isIgnored = isIgnored, isOk = isIgnored || allIsOk, + differenceCount = results.differenceCount, + ignoredCount = results.ignoredCount, ) } } @@ -68,6 +83,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 +95,8 @@ final class SeqDiffer[F[_], A]( pairType = PairType.ExpectedOnly, isIgnored = isIgnored, isOk = isIgnored, + differenceCount = expected.size, + ignoredCount = if (isIgnored) expected.size else 0, ) } @@ -148,10 +167,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: PairingFunction[A, B], itemDiffer: Differ[A], ): (Vector[DiffResult], Boolean) = { val matchedIndexes = mutable.BitSet.empty @@ -159,18 +178,25 @@ 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) => + def pushResult(res: => itemDiffer.R): Boolean = { + val memoizedRes = res + results += memoizedRes + matchedIndexes += idx + allIsOk &= memoizedRes.isOk + + true + } + + 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(diffRes) && pushResult(diffRes) + case fn: Custom[A, B] => + !matchedIndexes.contains(idx) && fn.matching(a, e) && pushResult(itemDiffer.diff(a, e)) + } } if (found.isEmpty) { @@ -179,12 +205,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 6f27c8a..6cadaf7 100644 --- a/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala +++ b/modules/core/src/main/scala/difflicious/differ/SetDiffer.scala @@ -3,14 +3,15 @@ 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, PairingFunction} final class SetDiffer[F[_], A]( isIgnored: Boolean, itemDiffer: Differ[A], - matchFunc: A => Any, + matchFunc: PairingFunction[A, Any], typeName: SomeTypeName, asSet: SetLike[F], ) extends Differ[F[A]] { @@ -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]] = @@ -83,7 +89,7 @@ final class SetDiffer[F[_], A]( new SetDiffer[F, A]( isIgnored = isIgnored, itemDiffer = itemDiffer, - matchFunc = m.func.asInstanceOf[A => Any], + matchFunc = PairingFunction.lift(m.func.asInstanceOf[A => Any]), typeName = typeName, asSet = asSet, ), @@ -99,7 +105,7 @@ object SetDiffer { ): SetDiffer[F, A] = new SetDiffer[F, A]( isIgnored = false, itemDiffer, - matchFunc = identity, + matchFunc = PairingFunction.approximative(threshold = 1).asInstanceOf[PairingFunction[A, Any]], typeName = typeName, asSet = asSet, ) 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 } + } +} 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]]],