├── .jvmopts ├── project ├── build.properties ├── plugins.sbt ├── TupleDifferInstancesGen.scala └── Build.scala ├── modules ├── core │ └── src │ │ └── main │ │ ├── scala │ │ └── difflicious │ │ │ ├── Derived.scala │ │ │ ├── PairType.scala │ │ │ ├── utils │ │ │ ├── SeqLike.scala │ │ │ ├── SetLike.scala │ │ │ ├── MapLike.scala │ │ │ ├── SubTypeOp.scala │ │ │ ├── Pairable.scala │ │ │ ├── TypeName.scala │ │ │ └── Eachable.scala │ │ │ ├── implicits.scala │ │ │ ├── internal │ │ │ ├── EitherGetSyntax.scala │ │ │ └── PairByOps.scala │ │ │ ├── ConfigurePath.scala │ │ │ ├── DiffInput.scala │ │ │ ├── ConfigureOp.scala │ │ │ ├── AlwaysIgnoreDiffer.scala │ │ │ ├── differ │ │ │ ├── ValueDiffer.scala │ │ │ ├── TransformedDiffer.scala │ │ │ ├── NumericDiffer.scala │ │ │ ├── EqualsDiffer.scala │ │ │ ├── RecordDiffer.scala │ │ │ ├── SetDiffer.scala │ │ │ ├── MapDiffer.scala │ │ │ └── SeqDiffer.scala │ │ │ ├── DifferTimeInstances.scala │ │ │ ├── ConfigureError.scala │ │ │ ├── DiffResult.scala │ │ │ ├── Differ.scala │ │ │ └── DiffResultPrinter.scala │ │ ├── scala-2.13 │ │ ├── generic │ │ │ ├── DerivedGen.scala │ │ │ └── package.scala │ │ └── difflicious │ │ │ ├── internal │ │ │ └── ConfigureMethods.scala │ │ │ └── DifferGen.scala │ │ └── scala-3 │ │ └── difflicious │ │ ├── generic │ │ └── package.scala │ │ ├── internal │ │ └── ConfigureMethods.scala │ │ └── DifferGen.scala ├── cats │ └── src │ │ ├── main │ │ └── scala │ │ │ └── difflicious │ │ │ └── cats │ │ │ ├── implicits.scala │ │ │ └── CatsInstances.scala │ │ └── test │ │ └── scala │ │ └── difflicious │ │ └── cats │ │ └── CatsDataDiffSpec.scala ├── coretest │ └── src │ │ └── test │ │ ├── scala │ │ └── difflicious │ │ │ ├── testutils │ │ │ ├── Inside.scala │ │ │ └── package.scala │ │ │ ├── testtypes.scala │ │ │ ├── DifferTimeInstancesSpec.scala │ │ │ ├── DifferConfigureSpec.scala │ │ │ └── DifferSpec.scala │ │ ├── scala-3 │ │ └── difflicious │ │ │ ├── ScalaVersionDependentTestTypes.scala │ │ │ ├── DifferAutoDerivationSpec.scala │ │ │ └── ScalaVersionDependentTests.scala │ │ └── scala-2.13 │ │ └── difflicious │ │ ├── ScalaVersionDependentTestTypes.scala │ │ ├── ScalaVersionDependentTests.scala │ │ └── DifferAutoDerivationSpec.scala ├── munit │ └── src │ │ └── main │ │ └── scala │ │ └── difflicious │ │ └── munit │ │ └── MUnitDiff.scala ├── scalatest │ └── src │ │ └── main │ │ ├── scala-3 │ │ └── difflicious.scalatest │ │ │ └── ScalatestDiff.scala │ │ └── scala-2.13 │ │ └── difflicious │ │ └── scalatest │ │ └── ScalatestDiff.scala ├── weaver │ └── src │ │ └── main │ │ ├── scala-3 │ │ └── difflicious.weaver │ │ │ └── WeaverDiff.scala │ │ └── scala-2.13 │ │ └── difflicious │ │ └── weaver │ │ └── WeaverDiff.scala └── benchmarks │ └── src │ └── main │ └── scala │ └── difflicious │ └── DiffBench.scala ├── docs ├── src │ └── main │ │ ├── resources │ │ └── microsite │ │ │ ├── img │ │ │ └── diff_failure.jpg │ │ │ ├── data │ │ │ └── menu.yml │ │ │ └── css │ │ │ └── main.css │ │ └── scala │ │ └── difflicious │ │ └── Example.scala └── docs │ ├── index.md │ └── docs │ ├── Cheatsheet.md │ ├── QuickStart.md │ ├── Introduction.md │ ├── LibraryIntegrations.md │ ├── BestPracticeAndFAQ.md │ ├── TypesOfDiffer.md │ └── ConfiguringDiffers.md ├── .github ├── release-drafter-config.yml └── workflows │ ├── release-drafter.yml │ ├── ci.yml │ └── clean.yml ├── .scalafmt.conf ├── .gitignore ├── README.md └── LICENSE /.jvmopts: -------------------------------------------------------------------------------- 1 | -Xmx3G 2 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.5 2 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/Derived.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | case class Derived[T](differ: Differ[T]) 4 | -------------------------------------------------------------------------------- /modules/cats/src/main/scala/difflicious/cats/implicits.scala: -------------------------------------------------------------------------------- 1 | package difflicious.cats 2 | 3 | object implicits extends CatsInstances 4 | -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/img/diff_failure.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jatcwang/difflicious/HEAD/docs/src/main/resources/microsite/img/diff_failure.jpg -------------------------------------------------------------------------------- /.github/release-drafter-config.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 4 | template: | 5 | ## Changes 6 | 7 | $CHANGES 8 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version="3.0.0-RC3" 2 | maxColumn = 120 3 | trailingCommas = always 4 | continuationIndent.defnSite = 2 5 | 6 | rewrite.rules = [PreferCurlyFors] 7 | rewrite.redundantBraces.stringInterpolation = true 8 | runner.dialect = scala3 9 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/PairType.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | sealed trait PairType 4 | 5 | object PairType { 6 | case object Both extends PairType 7 | case object ObtainedOnly extends PairType 8 | case object ExpectedOnly extends PairType 9 | } 10 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/utils/SeqLike.scala: -------------------------------------------------------------------------------- 1 | package difflicious.utils 2 | 3 | trait SeqLike[F[_]] { 4 | def asSeq[A](f: F[A]): Seq[A] 5 | } 6 | 7 | object SeqLike { 8 | implicit def stdSeqAsSeq[F[AA] <: Seq[AA]]: SeqLike[F] = new SeqLike[F] { 9 | override def asSeq[A](f: F[A]): Seq[A] = f 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/utils/SetLike.scala: -------------------------------------------------------------------------------- 1 | package difflicious.utils 2 | 3 | trait SetLike[F[_]] { 4 | def asSet[A](f: F[A]): Set[A] 5 | } 6 | 7 | object SetLike { 8 | implicit def stdSetAsSet[F[AA] <: Set[AA]]: SetLike[F] = new SetLike[F] { 9 | override def asSet[A](f: F[A]): Set[A] = f 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/implicits.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.internal.ToPairByOps 4 | import difflicious.utils.{EachableInstances, ToEachableOps, PairableInstances, ToSubTypeOp} 5 | 6 | object implicits extends ToEachableOps with EachableInstances with PairableInstances with ToSubTypeOp with ToPairByOps 7 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/utils/MapLike.scala: -------------------------------------------------------------------------------- 1 | package difflicious.utils 2 | 3 | trait MapLike[M[_, _]] { 4 | def asMap[A, B](m: M[A, B]): Map[A, B] 5 | } 6 | 7 | object MapLike { 8 | implicit def stdMapAsMap[M[AA, BB] <: Map[AA, BB]]: MapLike[M] = new MapLike[M] { 9 | override def asMap[A, B](m: M[A, B]): Map[A, B] = m 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/internal/EitherGetSyntax.scala: -------------------------------------------------------------------------------- 1 | package difflicious.internal 2 | 3 | import scala.annotation.nowarn 4 | 5 | private[difflicious] object EitherGetSyntax { 6 | implicit class EitherExtensionOps[A, B](val e: Either[A, B]) extends AnyVal { 7 | @nowarn("msg=.*deprecated.*") 8 | def unsafeGet: B = e.right.get 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.class 3 | *.log 4 | target/ 5 | project/target/ 6 | project/boot/ 7 | dist/ 8 | boot/ 9 | logs/ 10 | out/ 11 | tmp/ 12 | projectFilesBackup/ 13 | .history/ 14 | .idea/ 15 | .idea_modules/ 16 | .DS_STORE 17 | .cache 18 | .settings 19 | .project 20 | .classpath 21 | version.properties 22 | RUNNING_PID 23 | .metals 24 | metals.sbt 25 | .bsp 26 | TempGo.scala 27 | metals.sbt 28 | .bloop 29 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala/difflicious/testutils/Inside.scala: -------------------------------------------------------------------------------- 1 | package difflicious.testutils 2 | import munit.Assertions._ 3 | 4 | object Inside { 5 | 6 | // similar to scalatest's inside 7 | def inside[A](value: A)(pf: PartialFunction[A, Unit]): Unit = { 8 | if (pf.isDefinedAt(value)) { 9 | pf.apply(value) 10 | } else { 11 | fail(s"inside did not match for value: $value") 12 | } 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /modules/core/src/main/scala-2.13/generic/DerivedGen.scala: -------------------------------------------------------------------------------- 1 | package difflicious.generic 2 | 3 | import difflicious.Derived 4 | import magnolia1.Magnolia 5 | 6 | private[generic] object DerivedGen { 7 | 8 | import scala.reflect.macros.whitebox 9 | 10 | def derivedGen[T: c.WeakTypeTag](c: whitebox.Context): c.Expr[Derived[T]] = { 11 | import c.universe._ 12 | c.Expr[Derived[T]](q"difflicious.Derived(${Magnolia.gen[T](c)(implicitly[c.WeakTypeTag[T]])})") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/ConfigurePath.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | final case class ConfigurePath(resolvedSteps: Vector[String], unresolvedSteps: List[String]) 4 | 5 | object ConfigurePath { 6 | val current: ConfigurePath = of() 7 | 8 | def fromPath(steps: List[String]): ConfigurePath = { 9 | ConfigurePath(Vector.empty, steps) 10 | } 11 | 12 | def of(steps: String*): ConfigurePath = { 13 | ConfigurePath(Vector.empty, steps.toList) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/data/menu.yml: -------------------------------------------------------------------------------- 1 | options: 2 | - title: Introduction 3 | url: docs/introduction 4 | - title: Quickstart 5 | url: docs/quickstart 6 | - title: Types of Differs 7 | url: docs/types-of-differs 8 | - title: Configuring Differs 9 | url: docs/configuring-differs 10 | - title: Library Integrations 11 | url: docs/library-integrations 12 | - title: Best Practices / FAQ 13 | url: docs/best-practices-and-faq 14 | - title: Cheatsheet 15 | url: docs/cheatsheet 16 | -------------------------------------------------------------------------------- /modules/core/src/main/scala-2.13/generic/package.scala: -------------------------------------------------------------------------------- 1 | package difflicious.generic 2 | 3 | import difflicious.Derived 4 | import difflicious.DifferGen 5 | import difflicious.Differ 6 | 7 | package object auto extends AutoDerivation 8 | 9 | trait AutoDerivation extends DifferGen { 10 | 11 | implicit def derivedDiff[T](implicit dd: Derived[T]): Differ[T] = dd.differ 12 | 13 | implicit def diffForCaseClass[T]: Derived[T] = macro DerivedGen.derivedGen[T] 14 | 15 | def fallback[T]: Differ[T] = Differ.useEquals[T](_.toString) 16 | } 17 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala-3/difflicious/ScalaVersionDependentTestTypes.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import org.scalacheck.{Arbitrary, Gen} 4 | 5 | trait ScalaVersionDependentTestTypes: 6 | enum MyEnum { 7 | case I 8 | case V(i: Int) 9 | case XY(i: Int, j: String) 10 | } 11 | 12 | object MyEnum: 13 | given Differ[MyEnum] = Differ.derived[MyEnum] 14 | 15 | given Arbitrary[MyEnum] = Arbitrary( 16 | Gen.oneOf( 17 | Gen.const(MyEnum.I), 18 | Gen.posNum[Int].map(MyEnum.V.apply), 19 | ), 20 | ) 21 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - master 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Drafts your next Release notes as Pull Requests are merged into "master" 14 | - uses: release-drafter/release-drafter@v5 15 | with: 16 | config-name: release-drafter-config.yml 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 19 | -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/css/main.css: -------------------------------------------------------------------------------- 1 | /*.highlighter-rouge {*/ 2 | /* color: #c7254e;*/ 3 | /* background-color: #f9f2f4;*/ 4 | /*}*/ 5 | 6 | /*.hljs-string {*/ 7 | /* line-height: inherit !important;*/ 8 | /*}*/ 9 | 10 | .diff-render { 11 | background: aliceblue; 12 | border-radius: 5px; 13 | padding: 15px!important; 14 | } 15 | 16 | #content ul li { 17 | line-height: 24px; 18 | } 19 | 20 | .docs #page-content-wrapper section code.language-plaintext { 21 | background: #7bd88c3d; 22 | border-radius: 4px; 23 | padding: 1px 4px; 24 | } 25 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/utils/SubTypeOp.scala: -------------------------------------------------------------------------------- 1 | package difflicious.utils 2 | 3 | import scala.annotation.{compileTimeOnly, nowarn} 4 | 5 | // $COVERAGE-OFF$ 6 | trait SubTypeOp[A] { 7 | @compileTimeOnly("subClass should only be called in a Differ.configure* path") 8 | def subType[B <: A]: B = sys.error("subClass should only be called as part of path in Differ.configure*") 9 | } 10 | 11 | trait ToSubTypeOp { 12 | 13 | @nowarn("msg=.*never used.*") 14 | implicit def toSubTypeOp[A](a: A): SubTypeOp[A] = new SubTypeOp[A] {} 15 | } 16 | // $COVERAGE-ON$ 17 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/utils/Pairable.scala: -------------------------------------------------------------------------------- 1 | package difflicious.utils 2 | 3 | import scala.annotation.nowarn 4 | 5 | /** 6 | * A marker typeclass for some container that we can use pairBy when diffing 7 | */ 8 | // $COVERAGE-OFF$ 9 | trait Pairable[F[_]] 10 | 11 | object Pairable extends PairableInstances {} 12 | 13 | @nowarn("msg=.*never used.*") 14 | trait PairableInstances { 15 | implicit def seqPairable[F[_]: SeqLike]: Pairable[F] = new Pairable[F] {} 16 | implicit def setPairable[F[_]: SetLike]: Pairable[F] = new Pairable[F] {} 17 | } 18 | // $COVERAGE-ON$ 19 | -------------------------------------------------------------------------------- /modules/munit/src/main/scala/difflicious/munit/MUnitDiff.scala: -------------------------------------------------------------------------------- 1 | package difflicious.munit 2 | 3 | import difflicious.{Differ, DiffResultPrinter} 4 | import munit.Assertions._ 5 | import munit.Location 6 | 7 | trait MUnitDiff { 8 | implicit class DifferExtensions[A](differ: Differ[A]) { 9 | def assertNoDiff(obtained: A, expected: A)(implicit loc: Location): Unit = { 10 | val result = differ.diff(obtained, expected) 11 | if (!result.isOk) 12 | fail(DiffResultPrinter.consoleOutput(result, 0).render) 13 | } 14 | } 15 | } 16 | 17 | object MUnitDiff extends MUnitDiff 18 | -------------------------------------------------------------------------------- /modules/scalatest/src/main/scala-3/difflicious.scalatest/ScalatestDiff.scala: -------------------------------------------------------------------------------- 1 | package difflicious.scalatest 2 | 3 | import difflicious.{Differ, DiffResultPrinter} 4 | import org.scalactic.source.Position 5 | import org.scalatest.Assertions.fail 6 | 7 | trait ScalatestDiff { 8 | extension [A](differ: Differ[A]) 9 | inline def assertNoDiff(obtained: A, expected: A): Unit = { 10 | val result = differ.diff(obtained, expected) 11 | if (!result.isOk) 12 | fail(DiffResultPrinter.consoleOutput(result, 0).render) 13 | } 14 | } 15 | 16 | object ScalatestDiff extends ScalatestDiff 17 | -------------------------------------------------------------------------------- /modules/weaver/src/main/scala-3/difflicious.weaver/WeaverDiff.scala: -------------------------------------------------------------------------------- 1 | package difflicious.weaver 2 | 3 | import difflicious.{Differ, DiffResultPrinter} 4 | import weaver.Expectations 5 | import weaver.Expectations.Helpers.{failure, success} 6 | 7 | trait WeaverDiff { 8 | extension [A](differ: Differ[A]) 9 | inline def assertNoDiff(obtained: A, expected: A): Expectations = { 10 | val result = differ.diff(obtained, expected) 11 | if (!result.isOk) failure(DiffResultPrinter.consoleOutput(result, 0).render) 12 | else success 13 | } 14 | } 15 | 16 | object WeaverDiff extends WeaverDiff 17 | -------------------------------------------------------------------------------- /modules/scalatest/src/main/scala-2.13/difflicious/scalatest/ScalatestDiff.scala: -------------------------------------------------------------------------------- 1 | package difflicious.scalatest 2 | 3 | import difflicious.{Differ, DiffResultPrinter} 4 | import org.scalactic.source.Position 5 | import org.scalatest.Assertions.fail 6 | 7 | trait ScalatestDiff { 8 | implicit class DifferExtensions[A](differ: Differ[A]) { 9 | def assertNoDiff(obtained: A, expected: A)(implicit pos: Position): Unit = { 10 | val result = differ.diff(obtained, expected) 11 | if (!result.isOk) 12 | fail(DiffResultPrinter.consoleOutput(result, 0).render)(pos) 13 | } 14 | } 15 | } 16 | 17 | object ScalatestDiff extends ScalatestDiff 18 | -------------------------------------------------------------------------------- /modules/core/src/main/scala-3/difflicious/generic/package.scala: -------------------------------------------------------------------------------- 1 | package difflicious.generic 2 | 3 | import difflicious.Derived 4 | import difflicious.DifferGen 5 | import difflicious.Differ 6 | import magnolia1._ 7 | import scala.deriving.Mirror 8 | 9 | package object auto extends DerivedAutoDerivation 10 | 11 | trait DerivedAutoDerivation extends AutoDerivation[Differ] with DifferGen { 12 | 13 | given derivedDiff[T]: Conversion[Differ[T], Derived[T]] = d => Derived(d) 14 | 15 | inline implicit def diffForCaseClass[T](implicit m: Mirror.Of[T]): Derived[T] = Derived(derived[T]) 16 | 17 | def fallback[T]: Differ[T] = Differ.useEquals[T](_.toString) 18 | } 19 | -------------------------------------------------------------------------------- /modules/weaver/src/main/scala-2.13/difflicious/weaver/WeaverDiff.scala: -------------------------------------------------------------------------------- 1 | package difflicious.weaver 2 | 3 | import difflicious.{Differ, DiffResultPrinter} 4 | import weaver.{Expectations, SourceLocation} 5 | import weaver.Expectations.Helpers.{failure, success} 6 | 7 | trait WeaverDiff { 8 | implicit class DifferExtensions[A](differ: Differ[A]) { 9 | def assertNoDiff(obtained: A, expected: A)(implicit pos: SourceLocation): Expectations = { 10 | val result = differ.diff(obtained, expected) 11 | if (!result.isOk) failure(DiffResultPrinter.consoleOutput(result, 0).render) 12 | else success 13 | } 14 | } 15 | } 16 | 17 | object WeaverDiff extends WeaverDiff 18 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.2") 2 | addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.8") 3 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.6") 4 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.4.3") 5 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.2") 6 | addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.29.0") 7 | addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.8.1") // override mdoc version from microsite 8 | addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.11.0") 9 | // addSbtPlugin("com.47deg" % "sbt-microsites" % "1.4.4") 10 | 11 | // There are conflicts with scala-xml 1.0 vs 2.0 with microsites enabled 12 | // libraryDependencySchemes := Seq("org.scala-lang.modules" %% "scala-xml" %VersionScheme.Always) 13 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/internal/PairByOps.scala: -------------------------------------------------------------------------------- 1 | package difflicious.internal 2 | 3 | import difflicious.utils.Pairable 4 | import difflicious.{ConfigurePath, Differ} 5 | import difflicious.ConfigureOp.PairBy 6 | import difflicious.internal.EitherGetSyntax.EitherExtensionOps 7 | 8 | // pairBy has to be defined differently for better type inference. 9 | final class PairByOps[F[_], A](differ: Differ[F[A]]) { 10 | def pairBy[B](f: A => B): Differ[F[A]] = 11 | differ.configureRaw(ConfigurePath.current, PairBy.ByFunc(f)).unsafeGet 12 | 13 | def pairByIndex: Differ[F[A]] = 14 | differ.configureRaw(ConfigurePath.current, PairBy.Index).unsafeGet 15 | } 16 | 17 | trait ToPairByOps { 18 | implicit def toPairByOps[F[_]: Pairable, A](differ: Differ[F[A]]): PairByOps[F, A] = new PairByOps(differ) 19 | } 20 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/utils/TypeName.scala: -------------------------------------------------------------------------------- 1 | package difflicious.utils 2 | 3 | import izumi.reflect.macrortti.{LightTypeTag, LTag} 4 | 5 | final case class TypeName[A](long: String, short: String, typeArguments: List[TypeName[_]]) { 6 | def withTypeParamsLong: String = { 7 | s"$long${typeArguments.map(_.withTypeParamsLong).mkString("[", ",", "]")}" 8 | } 9 | } 10 | 11 | object TypeName { 12 | 13 | // A type name without a compile time type known. 14 | type SomeTypeName = TypeName[_] 15 | 16 | implicit def apply[A](implicit tag: LTag[A]): TypeName[A] = { 17 | fromLightTypeTag(tag.tag) 18 | } 19 | 20 | def fromLightTypeTag[A](t: LightTypeTag): TypeName[A] = { 21 | TypeName[A]( 22 | long = t.longNameWithPrefix, 23 | short = t.shortName, 24 | typeArguments = t.typeArgs.map(fromLightTypeTag), 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/src/main/scala/difflicious/Example.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | object Example { 4 | case class Person(name: String, age: Int) 5 | 6 | object Person { 7 | implicit val differ: Differ[Person] = Differ.derived[Person] 8 | } 9 | 10 | def printHtml(diffResult: DiffResult) = { 11 | 12 | val RED = "\u001b[31m" 13 | val GREEN = "\u001b[32m" 14 | val GRAY = "\u001b[90m" 15 | val RESET = "\u001b[39m" 16 | // val xx = difflicious.DiffResultPrinter 17 | // .consoleOutput(diffResult, 0) 18 | // .render 19 | difflicious.DiffResultPrinter 20 | .consoleOutput(diffResult, 0) 21 | .render 22 | .replace(RED, """""") 23 | .replace(GREEN, """""") 24 | .replace(GRAY, """""") 25 | .replace(RESET, "") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/DiffInput.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | /** 4 | * Input for diffing. We can either have both the left side (obtained) and right side (expected), 5 | * or just one side. 6 | * @tparam A 7 | */ 8 | sealed trait DiffInput[A] { 9 | def map[B](f: A => B): DiffInput[B] = { 10 | this match { 11 | case DiffInput.ObtainedOnly(obtained) => DiffInput.ObtainedOnly(f(obtained)) 12 | case DiffInput.ExpectedOnly(expected) => DiffInput.ExpectedOnly(f(expected)) 13 | case DiffInput.Both(obtained, expected) => DiffInput.Both(f(obtained), f(expected)) 14 | } 15 | } 16 | } 17 | 18 | object DiffInput { 19 | final case class ObtainedOnly[A](obtained: A) extends DiffInput[A] 20 | final case class ExpectedOnly[A](expected: A) extends DiffInput[A] 21 | final case class Both[A](obtained: A, expected: A) extends DiffInput[A] 22 | } 23 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala-2.13/difflicious/ScalaVersionDependentTestTypes.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.Differ 4 | import org.scalacheck.{Arbitrary, Gen} 5 | 6 | trait ScalaVersionDependentTestTypes { 7 | sealed trait SealedNested 8 | 9 | object SealedNested { 10 | case class SubFoo(i: Int) extends SealedNested 11 | 12 | sealed trait SubSealed extends SealedNested 13 | object SubSealed { 14 | case class SubSub1(d: Double) extends SubSealed 15 | case class SubSub2(s: String) extends SubSealed 16 | } 17 | 18 | import SubSealed._ 19 | implicit val arb: Arbitrary[SealedNested] = { 20 | val subFoo = Gen.posNum[Int].map(SubFoo.apply) 21 | val subsub1 = Gen.posNum[Double].map(SubSub1.apply) 22 | val subsub2 = Gen.alphaStr.map(SubSub2.apply) 23 | 24 | Arbitrary(Gen.oneOf(subFoo, subsub1, subsub2)) 25 | } 26 | implicit val differ: Differ[SealedNested] = Differ.derived[SealedNested] 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/ConfigureOp.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | /** 4 | * The configuration change operation we want to perform on a differ. 5 | * For example we might want to: 6 | * 7 | * - Mark the current differ as ignored so its comparison never fails 8 | * - Change a Differ for Seq to pair by a field instead of index 9 | */ 10 | sealed trait ConfigureOp 11 | 12 | object ConfigureOp { 13 | val ignore: SetIgnored = SetIgnored(true) 14 | val unignore: SetIgnored = SetIgnored(false) 15 | 16 | final case class SetIgnored(isIgnored: Boolean) extends ConfigureOp 17 | final case class TransformDiffer[T](func: Differ[T] => Differ[T]) extends ConfigureOp { 18 | def unsafeCastFunc[X]: Differ[X] => Differ[X] = func.asInstanceOf[Differ[X] => Differ[X]] 19 | } 20 | sealed trait PairBy[-A] extends ConfigureOp 21 | object PairBy { 22 | case object Index extends PairBy[Any] 23 | final case class ByFunc[A, B] private[difflicious] (func: A => B) extends PairBy[A] 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/AlwaysIgnoreDiffer.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.DiffResult.ValueResult 4 | 5 | /** A Differ that always return an Ignored result. 6 | * Useful when you can't really diff a type */ 7 | final class AlwaysIgnoreDiffer[T] extends Differ[T] { 8 | override type R = ValueResult 9 | 10 | override def diff(inputs: DiffInput[T]): ValueResult = 11 | ValueResult.Both("[ALWAYS IGNORED]", "[ALWAYS IGNORED]", isSame = true, isIgnored = true) 12 | 13 | override protected def configureIgnored(newIgnored: Boolean): Differ[T] = this 14 | 15 | override protected def configurePath( 16 | step: String, 17 | nextPath: ConfigurePath, 18 | op: ConfigureOp, 19 | ): Either[ConfigureError, Differ[T]] = Left(ConfigureError.PathTooLong(nextPath)) 20 | 21 | override protected def configurePairBy( 22 | path: ConfigurePath, 23 | op: ConfigureOp.PairBy[_], 24 | ): Either[ConfigureError, Differ[T]] = { 25 | Left(ConfigureError.InvalidConfigureOp(path, op, "AlwaysIgnoreDiffer")) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /modules/benchmarks/src/main/scala/difflicious/DiffBench.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import java.util.concurrent.TimeUnit 4 | 5 | import org.openjdk.jmh.annotations._ 6 | import org.openjdk.jmh.infra.Blackhole 7 | 8 | @State(Scope.Benchmark) 9 | @OutputTimeUnit(TimeUnit.NANOSECONDS) 10 | @BenchmarkMode(Array(Mode.AverageTime)) 11 | class DiffBench { 12 | 13 | val big = Big( 14 | 12, 15 | "asdfa", 16 | ) 17 | 18 | val bigClone = Big( 19 | 12, 20 | "asdfa", 21 | ) 22 | 23 | @Benchmark 24 | def usingDiff(bh: Blackhole): Unit = { 25 | bh.consume(Big.diff.diff(big, bigClone)) 26 | } 27 | 28 | @Benchmark 29 | def usingEquals(bh: Blackhole): Unit = { 30 | bh.consume(big == bigClone) 31 | } 32 | } 33 | 34 | final case class Big( 35 | i: Int, 36 | s: String, 37 | // map: Map[Key, Dog], 38 | // list: Vector[Dog], 39 | // set: Set[Dog], 40 | ) 41 | 42 | object Big { 43 | implicit val diff: Differ[Big] = Differ.derived[Big] 44 | } 45 | 46 | final case class Dog( 47 | name: String, 48 | age: Double, 49 | ) 50 | 51 | final case class Key( 52 | name: String, 53 | x: Int, 54 | ) 55 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/utils/Eachable.scala: -------------------------------------------------------------------------------- 1 | package difflicious.utils 2 | 3 | import difflicious.utils.Eachable.EachableOps 4 | 5 | import scala.annotation.{compileTimeOnly, nowarn} 6 | 7 | // $COVERAGE-OFF$ 8 | trait Eachable[F[_]] 9 | 10 | object Eachable extends EachableInstances { 11 | 12 | trait EachableOps[F[_], A] { 13 | @compileTimeOnly("each should only be called inside Differ configuration methods with path elements") 14 | def each: A = sys.error("each should only be called inside Differ configuration methods with path elements") 15 | } 16 | 17 | } 18 | 19 | @nowarn("msg=.*never used.*") 20 | trait EachableInstances { 21 | implicit def seqEachable[F[_]: SeqLike]: Eachable[F] = new Eachable[F] {} 22 | 23 | implicit def setEachable[F[_]: SetLike]: Eachable[F] = new Eachable[F] {} 24 | 25 | // Instance for Map directly for better inference 26 | implicit def mapEachable[K]: Eachable[Map[K, *]] = new Eachable[Map[K, *]] {} 27 | } 28 | 29 | trait ToEachableOps { 30 | @nowarn("msg=.*never used.*") 31 | implicit def toEachableOps[F[_]: Eachable, A](fa: F[A]): EachableOps[F, A] = new EachableOps[F, A] {} 32 | } 33 | // $COVERAGE-ON$ 34 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala-2.13/difflicious/ScalaVersionDependentTests.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.testtypes._ 4 | import difflicious.testtypes.SealedNested.SubSealed 5 | import difflicious.testutils._ 6 | import difflicious.implicits._ 7 | import munit.FunSuite 8 | 9 | trait ScalaVersionDependentTests { this: FunSuite => 10 | test("configure path's subType handles multi-level hierarchies") { 11 | assertConsoleDiffOutput( 12 | Differ[List[SealedNested]].ignoreAt(_.each.subType[SubSealed.SubSub1].d), 13 | List( 14 | SubSealed.SubSub1(1.0), 15 | ), 16 | List( 17 | SubSealed.SubSub1(2.0), 18 | ), 19 | s"""List( 20 | | SubSub1( 21 | | d: $grayIgnoredStr 22 | | ) 23 | |)""".stripMargin, 24 | ) 25 | } 26 | 27 | test("configure path's subType call errors when super type isn't sealed") { 28 | val compileError = compileErrors("Differ[List[OpenSuperType]].ignoreAt(_.each.subType[OpenSub])") 29 | val firstLine = compileError.linesIterator.toList.drop(1).head 30 | assertEquals(firstLine, "Specified subtype is not a known direct subtype of trait OpenSuperType.") 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/differ/ValueDiffer.scala: -------------------------------------------------------------------------------- 1 | package difflicious.differ 2 | 3 | import difflicious.{Differ, DiffResult, ConfigureOp, ConfigureError, ConfigurePath, DiffInput} 4 | 5 | /** 6 | * Differ where the error diagnostic output is just string values. 7 | * Simple types where a string representation is enough for diagnostics purposes 8 | * should use Differ.useEquals (EqualsDiffer is a subtype of this trait). 9 | * For example, Differ for Int, String, java.time.Instant are all ValueDiffers. 10 | * 11 | * This trait also provide an extra `contramap` method which makes it easy to 12 | * write instances for newtypes / opaque types. 13 | */ 14 | trait ValueDiffer[T] extends Differ[T] { 15 | final override type R = DiffResult.ValueResult 16 | 17 | override def diff(inputs: DiffInput[T]): R 18 | 19 | override def configureIgnored(newIgnored: Boolean): ValueDiffer[T] 20 | 21 | override def configurePath( 22 | step: String, 23 | nextPath: ConfigurePath, 24 | op: ConfigureOp, 25 | ): Either[ConfigureError, ValueDiffer[T]] 26 | 27 | override def configurePairBy( 28 | path: ConfigurePath, 29 | op: ConfigureOp.PairBy[_], 30 | ): Either[ConfigureError, ValueDiffer[T]] 31 | 32 | final def contramap[S](transformFunc: S => T): TransformedDiffer[S, T] = { 33 | new TransformedDiffer(this, transformFunc) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | title: "Home" 4 | section: "home" 5 | position: 1 6 | --- 7 | 8 | Difflicious helps you find and compare the differences between values. 9 | 10 | [![Release](https://img.shields.io/nexus/r/com.github.jatcwang/difflicious-munit_2.13?server=https%3A%2F%2Foss.sonatype.org)](https://oss.sonatype.org/content/repositories/releases/com/github/jatcwang/difflicious-munit_2.13/) 11 | [![(https://badges.gitter.im/gitterHQ/gitter.png)](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/jatcwang/difflicious) 12 | 13 | - Readable diff results 14 | - Flexible diffing logic 15 | - Ignore unimportant fields when comparing 16 | - Compare `List`s of items independent of order 17 | - Match `Map` entries by key and show diffs of the values 18 | - Integration with test frameworks and popular libraries 19 | 20 | # Installation 21 | 22 | If you're using the [MUnit](https://scalameta.org/munit/) test framework: 23 | ``` 24 | // == SBT == 25 | "com.github.jatcwang" %% "difflicious-munit" % "{{ site.version }}" 26 | // == Mill == 27 | ivy"com.github.jatcwang::difflicious-munit:{{ site.version }}" 28 | ``` 29 | 30 | If you're using [ScalaTest](https://www.scalatest.org/) test framework: 31 | ``` 32 | // == SBT == 33 | "com.github.jatcwang" %% "difflicious-scalatest" % "{{ site.version }}" 34 | // == Mill == 35 | ivy"com.github.jatcwang::difflicious-scalatest:{{ site.version }}" 36 | ``` 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Difflicious 2 | 3 | > Diffs for human consumption 4 | 5 | [![Release](https://img.shields.io/nexus/r/com.github.jatcwang/difflicious-munit_2.13?server=https%3A%2F%2Foss.sonatype.org)](https://oss.sonatype.org/content/repositories/releases/com/github/jatcwang/difflicious-munit_2.13/) 6 | [![(https://badges.gitter.im/gitterHQ/gitter.png)](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/jatcwang/difflicious) 7 | 8 | **Difflicious** is a library that produces nice readable diffs in your tests. 9 | 10 | - Readable and Actionable diff results 11 | - Flexible & Configurable diffing logic 12 | - Ignore unimportant fields when comparing 13 | - Compare `List`s of items independent of order 14 | - Match `Map` entries by key and show diffs of the values 15 | - Integration with test frameworks and popular libraries 16 | 17 | Hungry for some good diffs? Check out the [documentation](https://jatcwang.github.io/difflicious/)! 18 | 19 | # Contributing 20 | 21 | All contributions are welcome including suggestions and ideas. For larger changes please raise an issue 22 | first to avoid duplicate work :) 23 | 24 | # Attributions 25 | 26 | This project takes many inspirations from 27 | 28 | - [diffx](https://github.com/softwaremill/diffx)'s path expression for ignoring fields 29 | - [MUnit](https://scalameta.org/munit/)'s case class diffs 30 | 31 | # License 32 | 33 | **Apache License 2.0**. See LICENSE file. 34 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/differ/TransformedDiffer.scala: -------------------------------------------------------------------------------- 1 | package difflicious.differ 2 | 3 | import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, DiffResult} 4 | 5 | /** 6 | * A Differ that transforms any input of diff method and pass it to its underlying Differ. 7 | * See [[ValueDiffer.contramap]] 8 | */ 9 | class TransformedDiffer[T, U](underlyingDiffer: ValueDiffer[U], transformFunc: T => U) extends ValueDiffer[T] { 10 | override def diff(inputs: DiffInput[T]): DiffResult.ValueResult = underlyingDiffer.diff(inputs.map(transformFunc)) 11 | 12 | override def configureIgnored(newIgnored: Boolean): TransformedDiffer[T, U] = { 13 | new TransformedDiffer[T, U]( 14 | underlyingDiffer.configureIgnored(newIgnored), 15 | transformFunc, 16 | ) 17 | } 18 | 19 | override def configurePath( 20 | step: String, 21 | nextPath: ConfigurePath, 22 | op: ConfigureOp, 23 | ): Either[ConfigureError, TransformedDiffer[T, U]] = { 24 | underlyingDiffer.configurePath(step, nextPath, op).map { newUnderlyingDiffer => 25 | new TransformedDiffer( 26 | newUnderlyingDiffer, 27 | transformFunc, 28 | ) 29 | } 30 | } 31 | 32 | override def configurePairBy( 33 | path: ConfigurePath, 34 | op: ConfigureOp.PairBy[_], 35 | ): Either[ConfigureError, TransformedDiffer[T, U]] = { 36 | underlyingDiffer.configurePairBy(path, op).map { newUnderlyingDiffer => 37 | new TransformedDiffer( 38 | newUnderlyingDiffer, 39 | transformFunc, 40 | ) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /project/TupleDifferInstancesGen.scala: -------------------------------------------------------------------------------- 1 | object TupleDifferInstancesGen { 2 | val fileContent: String = { 3 | val iter = (2 to 22) 4 | .map { tupleSize => 5 | val typeList = (1 to tupleSize).map(t => s"A$t").mkString(", ") 6 | val tupleName = s"Tuple$tupleSize" 7 | val typeName = s"$tupleName[${typeList}]" 8 | 9 | s""" 10 | |implicit def tuple$tupleSize[$typeList]( 11 | | implicit ${(1 to tupleSize) 12 | .map { t => 13 | s"a${t}Diff: Differ[A$t]" 14 | } 15 | .mkString(",\n ")}, 16 | | typeName: TypeName[${typeName}] 17 | |): RecordDiffer[$typeName] = new RecordDiffer[$typeName]( 18 | | ListMap( 19 | | ${(1 to tupleSize) 20 | .map { t => 21 | s""""_$t" -> Tuple2(_._$t, a${t}Diff.asInstanceOf[Differ[Any]])""" 22 | } 23 | .mkString(",\n ")} 24 | | ), 25 | | isIgnored = false, 26 | | typeName = typeName, 27 | |) 28 | |""".stripMargin 29 | } 30 | .mkString("\n") 31 | .linesIterator 32 | .map(s => s" $s") 33 | .mkString("\n") 34 | 35 | s""" 36 | |package difflicious 37 | | 38 | |import difflicious.differ.RecordDiffer 39 | |import difflicious.utils.TypeName 40 | | 41 | |import scala.collection.immutable.ListMap 42 | |// $$COVERAGE-OFF$$ 43 | |trait DifferTupleInstances { 44 | | ${iter} 45 | |} 46 | |// $$COVERAGE-ON$$ 47 | |""".stripMargin 48 | 49 | 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/docs/docs/Cheatsheet.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: docs 3 | title: "Cheatsheet" 4 | permalink: docs/cheatsheet 5 | --- 6 | 7 | ### Basic imports 8 | 9 | ```scala mdoc:invisible 10 | import difflicious.Example._ 11 | ``` 12 | 13 | ```scala mdoc:silent 14 | import difflicious._ 15 | import difflicious.implicits._ 16 | ``` 17 | 18 | ### Summoning Differ instances 19 | 20 | ```scala mdoc:silent 21 | val intListDiffer = Differ[List[Int]] 22 | ``` 23 | 24 | ### Deriving instances for case class and sealed traits (Scala 3 enums) 25 | 26 | ```scala mdoc:nest:silent 27 | val differ = Differ.derived[Person] 28 | ``` 29 | 30 | For classes with generic fields, you need to also ask for Differ instance of the field type (not just the generic type). 31 | 32 | ```scala mdoc:silent:nest 33 | case class Box[A]( 34 | content: List[A] 35 | ) 36 | 37 | case class Factory[A]( 38 | boxes: List[Box[A]] 39 | ) 40 | 41 | implicit def boxDiffer[A](implicit listDiffer: Differ[List[A]]): Differ[Box[A]] = Differ.derived[Box[A]] 42 | implicit def factoryDiffer[A](implicit boxesDiffer: Differ[List[Box[A]]]): Differ[Factory[A]] = Differ.derived[Factory[A]] 43 | 44 | val differ = Differ[Factory[Int]] 45 | ``` 46 | 47 | ### Configuring Differs 48 | 49 | ```scala mdoc:compile-only 50 | val differ = Differ[Map[String, List[Person]]] 51 | 52 | differ.configure(_.each)(_.pairBy(_.name)) 53 | differ.configure(_.each)(_.pairByIndex) 54 | 55 | differ.ignoreAt(_.each.each.name) 56 | // Equivalent to differ.configure(_.each.each.name)(_.ignore) 57 | 58 | // Replacing a differ at path 59 | val anotherPersonListDiffer: Differ[List[Person]] = ??? 60 | differ.replace(_.each)(anotherPersonListDiffer) 61 | ``` 62 | 63 | 64 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/DifferTimeInstances.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | import java.time._ 3 | import difflicious.differ.EqualsDiffer 4 | 5 | trait DifferTimeInstances { 6 | 7 | implicit val dayOfWeekDiffer: EqualsDiffer[DayOfWeek] = { 8 | Differ.useEquals(_.toString) 9 | } 10 | 11 | implicit val durationDiffer: EqualsDiffer[Duration] = Differ.useEquals(_.toString) 12 | 13 | implicit val instantDiffer: EqualsDiffer[Instant] = Differ.useEquals(_.toString) 14 | 15 | implicit val localDateDiffer: EqualsDiffer[LocalDate] = Differ.useEquals(_.toString) 16 | 17 | implicit val localDateTimeDiffer: EqualsDiffer[LocalDateTime] = Differ.useEquals(_.toString) 18 | 19 | implicit val localTimeDiffer: EqualsDiffer[LocalTime] = Differ.useEquals(_.toString) 20 | 21 | implicit val monthDiffer: EqualsDiffer[Month] = Differ.useEquals(_.toString) 22 | 23 | implicit val monthDayDiffer: EqualsDiffer[MonthDay] = Differ.useEquals(_.toString) 24 | 25 | implicit val offsetDateTimeDiffer: EqualsDiffer[OffsetDateTime] = Differ.useEquals(_.toString) 26 | 27 | implicit val offsetTimeDiffer: EqualsDiffer[OffsetTime] = Differ.useEquals(_.toString) 28 | 29 | implicit val periodDiffer: EqualsDiffer[Period] = Differ.useEquals(_.toString) 30 | 31 | implicit val yearDiffer: EqualsDiffer[Year] = Differ.useEquals(_.toString) 32 | 33 | implicit val yearMonthDiffer: EqualsDiffer[YearMonth] = Differ.useEquals(_.toString) 34 | 35 | implicit val zonedDateTimeDiffer: EqualsDiffer[ZonedDateTime] = Differ.useEquals(_.toString) 36 | 37 | implicit val zoneIdDiffer: EqualsDiffer[ZoneId] = Differ.useEquals(_.toString) 38 | 39 | implicit val zoneOffsetDiffer: EqualsDiffer[ZoneOffset] = Differ.useEquals(_.toString) 40 | 41 | } 42 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala-2.13/difflicious/DifferAutoDerivationSpec.scala: -------------------------------------------------------------------------------- 1 | package difflicioustest 2 | 3 | import munit.FunSuite 4 | 5 | class DifferAutoDerivationSpec extends FunSuite { 6 | test("should not compile without instance in scope") { 7 | val result = compileErrors(""" 8 | import difflicious._ 9 | final case class P1(f1: String) 10 | 11 | val p1: Differ[P1] = Differ.derived[P1] 12 | 13 | Differ[P1].diff(P1("a"), P1("b")) 14 | """) 15 | assert(result.contains("could not find implicit")) 16 | } 17 | 18 | test("should find auto derived instance for product") { 19 | val result = compileErrors(""" 20 | import difflicious._ 21 | import difflicious.generic.auto._ 22 | 23 | final case class P1(f1: String) 24 | 25 | Differ[P1].diff(P1("a"), P1("b")) 26 | """) 27 | assertNoDiff(result, "") 28 | } 29 | test("should put auto derived instance back into scope") { 30 | val result = compileErrors(""" 31 | import difflicious._ 32 | import difflicious.generic.auto._ 33 | 34 | final case class P1(f1: String) 35 | implicit val d: Differ[P1] = implicitly[Derived[P1]].differ 36 | 37 | Differ[P1].diff(P1("a"), P1("b")) 38 | """) 39 | assertNoDiff(result, "") 40 | } 41 | test("should use manually defined instance for an element") { 42 | import difflicious._ 43 | import difflicious.generic.auto._ 44 | 45 | final case class P1(f1: String, f2: String) 46 | final case class P2(p1: P1) 47 | implicit val d: Differ[P1] = implicitly[Derived[P1]].differ.ignoreAt(_.f1) 48 | 49 | val r = Differ[P2].diff(P2(P1("a", "a")), P2(P1("b", "a"))) 50 | assertEquals(r.isOk, true) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/ConfigureError.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | sealed trait ConfigureError extends Throwable { 4 | def errorMessage: String 5 | override def getMessage: String = errorMessage 6 | } 7 | 8 | object ConfigureError { 9 | private def resolvedPath(path: ConfigurePath): String = path.resolvedSteps.mkString(".") 10 | 11 | private def unresolvedPath(path: ConfigurePath): String = path.unresolvedSteps.mkString(".") 12 | 13 | final case class NonExistentField(path: ConfigurePath, dfferType: String) extends ConfigureError { 14 | override def errorMessage: String = s"Field does not exist at path ${resolvedPath(path)}" 15 | } 16 | final case class PathTooLong(path: ConfigurePath) extends ConfigureError { 17 | override def errorMessage: String = 18 | s"Configure path is too long. Reached a 'leaf' Differ while there are still unresolved steps. " + 19 | s"Current path: ${resolvedPath(path)}, Leftover steps: ${unresolvedPath(path)}" 20 | } 21 | final case class PathTooShortForReplace() extends ConfigureError { 22 | override def errorMessage: String = "Path cannot be empty for Replace command" 23 | } 24 | final case class UnrecognizedSubType(path: ConfigurePath, allowedTypes: Vector[String]) extends ConfigureError { 25 | override def errorMessage: String = 26 | s"Unrecognized subtype at path ${resolvedPath(path)}. Known types are ${allowedTypes.mkString(",")}" 27 | } 28 | final case class InvalidConfigureOp(path: ConfigurePath, op: ConfigureOp, differType: String) extends ConfigureError { 29 | override def errorMessage: String = 30 | s"The differ you're trying to configure (${differType}) does now allow the provided ConfigureOp ${op}" + 31 | s"Current path: ${resolvedPath(path)}" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/differ/NumericDiffer.scala: -------------------------------------------------------------------------------- 1 | package difflicious.differ 2 | 3 | import difflicious.{DiffResult, ConfigureOp, ConfigureError, ConfigurePath, DiffInput} 4 | 5 | final class NumericDiffer[T](isIgnored: Boolean, numeric: Numeric[T]) extends ValueDiffer[T] { 6 | @inline 7 | private def valueToString(t: T) = t.toString 8 | 9 | override def diff(inputs: DiffInput[T]): DiffResult.ValueResult = inputs match { 10 | case DiffInput.Both(obtained, expected) => { 11 | DiffResult.ValueResult.Both( 12 | valueToString(obtained), 13 | valueToString(expected), 14 | isSame = numeric.equiv(obtained, expected), 15 | isIgnored = isIgnored, 16 | ) 17 | } 18 | case DiffInput.ObtainedOnly(obtained) => 19 | DiffResult.ValueResult.ObtainedOnly(valueToString(obtained), isIgnored = isIgnored) 20 | case DiffInput.ExpectedOnly(expected) => 21 | DiffResult.ValueResult.ExpectedOnly(valueToString(expected), isIgnored = isIgnored) 22 | } 23 | 24 | override def configureIgnored(newIgnored: Boolean): NumericDiffer[T] = 25 | new NumericDiffer[T](isIgnored = newIgnored, numeric = numeric) 26 | 27 | override def configurePath( 28 | step: String, 29 | nextPath: ConfigurePath, 30 | op: ConfigureOp, 31 | ): Either[ConfigureError, NumericDiffer[T]] = Left(ConfigureError.PathTooLong(nextPath)) 32 | 33 | override def configurePairBy( 34 | path: ConfigurePath, 35 | op: ConfigureOp.PairBy[_], 36 | ): Either[ConfigureError, NumericDiffer[T]] = 37 | Left(ConfigureError.InvalidConfigureOp(path, op, "NumericDiffer")) 38 | 39 | } 40 | 41 | object NumericDiffer { 42 | def make[T](implicit numeric: Numeric[T]): NumericDiffer[T] = 43 | new NumericDiffer[T](isIgnored = false, numeric = numeric) 44 | } 45 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/differ/EqualsDiffer.scala: -------------------------------------------------------------------------------- 1 | package difflicious.differ 2 | 3 | import difflicious.ConfigureOp.PairBy 4 | import difflicious.{ConfigureError, ConfigureOp, ConfigurePath, DiffInput, DiffResult} 5 | 6 | /** 7 | * Differ where the two values are compared by using the equals method. 8 | * If the two values aren't equal, then we use the provided valueToString function 9 | * to output the diagnostic output. 10 | */ 11 | final class EqualsDiffer[T](isIgnored: Boolean, valueToString: T => String) extends ValueDiffer[T] { 12 | override def diff(inputs: DiffInput[T]): DiffResult.ValueResult = inputs match { 13 | case DiffInput.Both(obtained, expected) => 14 | DiffResult.ValueResult 15 | .Both( 16 | obtained = valueToString(obtained), 17 | expected = valueToString(expected), 18 | isSame = obtained == expected, 19 | isIgnored = isIgnored, 20 | ) 21 | case DiffInput.ObtainedOnly(obtained) => 22 | DiffResult.ValueResult.ObtainedOnly(valueToString(obtained), isIgnored = isIgnored) 23 | case DiffInput.ExpectedOnly(expected) => 24 | DiffResult.ValueResult.ExpectedOnly(valueToString(expected), isIgnored = isIgnored) 25 | } 26 | 27 | override def configureIgnored(newIgnored: Boolean): EqualsDiffer[T] = 28 | new EqualsDiffer[T](isIgnored = newIgnored, valueToString = valueToString) 29 | 30 | override def configurePath( 31 | step: String, 32 | nextPath: ConfigurePath, 33 | op: ConfigureOp, 34 | ): Either[ConfigureError, EqualsDiffer[T]] = Left(ConfigureError.PathTooLong(nextPath)) 35 | 36 | override def configurePairBy(path: ConfigurePath, op: PairBy[_]): Either[ConfigureError, EqualsDiffer[T]] = 37 | Left(ConfigureError.InvalidConfigureOp(path, op, "EqualsDiffer")) 38 | 39 | } 40 | -------------------------------------------------------------------------------- /docs/docs/docs/QuickStart.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: docs 3 | title: "Quickstart" 4 | permalink: docs/quickstart 5 | --- 6 | 7 | # Quickstart 8 | 9 | Let's see how you can use Difflicious in your MUnit tests 10 | 11 | First, add the following dependency in your SBT configuration: 12 | 13 | ``` 14 | "com.github.jatcwang" %% "difflicious-munit" % "{{ site.version }}" % Test 15 | ``` 16 | 17 | If you are running tests using **IntelliJ IDEA**'s test runner, you will want 18 | to turn off the red text coloring it uses for test failure outputs because 19 | it interferes with difflicious' color outputs. 20 | 21 | In File | Settings | Editor | Color Scheme | Console Colors | Console | Error Output, uncheck the red foreground color. 22 | 23 | Let's say you have some case classes.. 24 | 25 | ```scala mdoc:silent 26 | case class Person( 27 | name: String, 28 | age: Int, 29 | occupation: String 30 | ) 31 | ``` 32 | 33 | To perform diffs with Difflicious, you will need to derive some `Differs` 34 | 35 | ```scala mdoc:silent 36 | import munit.FunSuite 37 | import difflicious.Differ 38 | import difflicious.munit.MUnitDiff._ 39 | 40 | class ExampleTest extends FunSuite { 41 | 42 | // Derive Differs for case class and sealed traits 43 | implicit val personDiffer: Differ[Person] = Differ.derived[Person] 44 | 45 | test("two list of people should be the same") { 46 | Differ[List[Person]].assertNoDiff( 47 | List( 48 | Person("Alice", 50, "Doctor") 49 | ), 50 | List( 51 | Person("Alice", 40, "Doctor"), 52 | Person("Bob", 30, "Teacher") 53 | ) 54 | ) 55 | } 56 | } 57 | ``` 58 | 59 | Run the tests, and you should see a nice failure diff: 60 | 61 | 62 | 63 | Pretty right? You should explore the next sections of the documentation and learn about the different Differs 64 | and how you can configure them! 65 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala-3/difflicious/DifferAutoDerivationSpec.scala: -------------------------------------------------------------------------------- 1 | package difflicioustest 2 | 3 | import munit.ScalaCheckSuite 4 | import DifferAutoDerivationSpec.P1 5 | 6 | class DifferAutoDerivationSpec extends ScalaCheckSuite { 7 | test("should not compile without instance in scope") { 8 | val result = compileErrors( 9 | """ 10 | import difflicious._ 11 | final case class P1(f1: String) 12 | 13 | val p1: Differ[P1] = Differ.derived[P1] 14 | 15 | Differ[P1].diff(P1("a"), P1("b")) 16 | """) 17 | assertNoDiff( 18 | result, 19 | """error: No given instance of type difflicious.Differ[P1] was found for parameter differ of method apply in object Differ 20 | | Differ[P1].diff(P1("a"), P1("b")) 21 | | ^ 22 | |""".stripMargin 23 | ) 24 | } 25 | test("should find auto derived instance for product") { 26 | val result = compileErrors( 27 | """ 28 | import difflicious._ 29 | import difflicious.generic.auto.given 30 | 31 | Differ[P1].diff(P1("a"), P1("b")) 32 | """) 33 | assertNoDiff(result, "") 34 | } 35 | test("should put auto derived instance back into scope") { 36 | val result = compileErrors( 37 | """ 38 | import difflicious._ 39 | import difflicious.generic.auto.given 40 | 41 | implicit val d: Differ[P1] = implicitly[Derived[P1]].differ 42 | 43 | Differ[P1].diff(P1("a"), P1("b")) 44 | """) 45 | assertNoDiff(result, "") 46 | } 47 | test("should use manually defined instance for an element") { 48 | import difflicious._ 49 | import difflicious.generic.auto._ 50 | import difflicious.generic.auto.given 51 | 52 | final case class Pi(f1: String, f2: String) 53 | final case class P2(p1: Pi) 54 | implicit val d: Differ[Pi] = implicitly[Derived[Pi]].differ.ignoreAt(_.f1) 55 | 56 | val r = Differ[P2].diff(P2(Pi("a", "a")), P2(Pi("b", "a"))) 57 | assertEquals(r.isOk, true) 58 | } 59 | } 60 | 61 | object DifferAutoDerivationSpec { 62 | final case class P1(f1: String) 63 | } 64 | -------------------------------------------------------------------------------- /docs/docs/docs/Introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: docs 3 | title: "Introduction" 4 | permalink: docs/introduction 5 | --- 6 | 7 | # Introduction 8 | 9 | **Difflicious** is a library that produces nice readable diffs in your tests. 10 | 11 | * **Readable** and **Actionable** diffs 12 | * **Customizability**: supporting all kinds of tweaks you'd want to do such as ignoring fields or compare lists independent of element order. 13 | 14 | Here's a motivational example! 15 | 16 | ```scala mdoc:silent 17 | import difflicious._ 18 | import difflicious.implicits._ 19 | 20 | sealed trait HousePet { 21 | def name: String 22 | } 23 | object HousePet { 24 | final case class Dog(name: String, age: Int) extends HousePet 25 | final case class Cat(name: String, livesLeft: Int) extends HousePet 26 | 27 | implicit val differ: Differ[HousePet] = Differ.derived 28 | } 29 | 30 | import HousePet.{Cat, Dog} 31 | 32 | val petsDiffer = Differ[List[HousePet]] 33 | .pairBy(_.name) // Match pets in the list by name for comparison 34 | .ignoreAt(_.each.subType[Cat].livesLeft) // Don't worry about livesLeft for cats when comparing 35 | 36 | petsDiffer.diff( 37 | obtained = List( 38 | Dog("Andy", 12), 39 | Cat("Dr.Evil", 8), 40 | Dog("Lucky", 5), 41 | ), 42 | expected = List( 43 | Dog("Lucky", 6), 44 | Cat("Dr.Evil", 9), 45 | Cat("Andy", 12), 46 | ) 47 | ) 48 | ``` 49 | 50 | And this is the diffs you will see: 51 | 52 |
53 | List(
54 |   Dog != Cat
55 |   === Obtained ===
56 |   Dog(
57 |     name: "Andy",
58 |     age: 12,
59 |   )
60 |   === Expected ===
61 |   Cat(
62 |     name: "Andy",
63 |     livesLeft: [IGNORED],
64 |   ),
65 |   Cat(
66 |     name: "Dr.Evil",
67 |     livesLeft: [IGNORED],
68 |   ),
69 |   Dog(
70 |     name: "Lucky",
71 |     age: 5 -> 6,
72 |   ),
73 | )
74 | 
75 | 76 | In the example, we can see that: 77 | 78 | * Difflicious spots that **Andy** is not a Dog but instead a Cat!! 79 | * The cat **Dr.Evil** is considered to be the same on both sides, because we decided to not check how many lives 80 | the cats have left. 81 | * A diff is produced showing us that **Lucky's** age is wrong. 82 | -------------------------------------------------------------------------------- /docs/docs/docs/LibraryIntegrations.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: docs 3 | title: "Library Integrations" 4 | permalink: docs/library-integrations 5 | --- 6 | 7 | # Library Integrations 8 | 9 | ## MUnit 10 | 11 | Add this to your SBT build 12 | ``` 13 | "com.github.jatcwang" %% "difflicious-munit" % "{{ site.version }}" % Test 14 | ``` 15 | 16 | and then in your test suites you can call `assertNoDiff` on any `Differ`. 17 | 18 | ```scala mdoc:nest 19 | import munit.FunSuite 20 | import difflicious.munit.MUnitDiff._ 21 | import difflicious.Differ 22 | 23 | class MyTest extends FunSuite { 24 | test("a == b") { 25 | Differ[Int].assertNoDiff(1, 2) 26 | } 27 | } 28 | ``` 29 | 30 | ## Scalatest 31 | 32 | Add this to your SBT build 33 | ``` 34 | "com.github.jatcwang" %% "difflicious-scalatest" % "{{ site.version }}" % Test 35 | ``` 36 | 37 | Tests should be run with the `-oW` option to disable Scalatest from coloring test failures all red as it interferes with 38 | difflicious color display. 39 | 40 | ``` 41 | testOnly -- -oW 42 | ``` 43 | 44 | Here's an example of what a test using difflicious looks like: 45 | 46 | ```scala mdoc:nest 47 | import org.scalatest.funsuite.AnyFunSuite 48 | import difflicious.scalatest.ScalatestDiff._ 49 | import difflicious.Differ 50 | 51 | class MyTest extends AnyFunSuite { 52 | test("a == b") { 53 | Differ[Int].assertNoDiff(1, 2) 54 | } 55 | } 56 | ``` 57 | 58 | ## Weaver 59 | 60 | Add this to your SBT build 61 | ``` 62 | "com.github.jatcwang" %% "difflicious-weaver" % "{{ site.version }}" % Test 63 | ``` 64 | 65 | and then in your test suites you can call `assertNoDiff` on any `Differ`. 66 | 67 | ```scala mdoc:nest 68 | import weaver.SimpleIOSuite 69 | import difflicious.weaver.WeaverDiff._ 70 | import difflicious.Differ 71 | 72 | object MyTest extends SimpleIOSuite { 73 | pureTest("a == b") { 74 | Differ[Int].assertNoDiff(1, 2) 75 | } 76 | } 77 | ``` 78 | 79 | ## Cats 80 | 81 | Differ instances for cats data structures like `NonEmptyList` and `Chain` can be found in 82 | 83 | ``` 84 | "com.github.jatcwang" %% "difflicious-cats" % "{{ site.version }}" % Test 85 | ``` 86 | 87 | ```scala mdoc:nest 88 | import difflicious.Differ 89 | import difflicious.cats.implicits._ 90 | import cats.data.{NonEmptyMap, NonEmptyList} 91 | 92 | val differ: Differ[List[NonEmptyMap[String, NonEmptyList[Int]]]] = Differ[List[NonEmptyMap[String, NonEmptyList[Int]]]] 93 | ``` 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['**'] 13 | push: 14 | branches: ['**'] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | jobs: 21 | build: 22 | name: Build and Test 23 | strategy: 24 | matrix: 25 | os: [ubuntu-latest] 26 | scala: [2_13, 3_0] 27 | java: [temurin@11] 28 | scalaPlatform: [jvm] 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - name: Checkout current branch (full) 32 | uses: actions/checkout@v6 33 | with: 34 | fetch-depth: 0 35 | 36 | - name: Setup Java (temurin@11) 37 | if: matrix.java == 'temurin@11' 38 | uses: actions/setup-java@v5 39 | with: 40 | distribution: temurin 41 | java-version: 11 42 | cache: sbt 43 | 44 | - name: Setup sbt 45 | uses: sbt/setup-sbt@v1 46 | 47 | - name: Check that workflows are up to date 48 | run: sbt githubWorkflowCheck 49 | 50 | - name: Build project 51 | run: sbt test 52 | 53 | publish: 54 | name: Publish Artifacts 55 | needs: [build] 56 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) 57 | strategy: 58 | matrix: 59 | os: [ubuntu-latest] 60 | scala: [2.13.18] 61 | java: [temurin@11] 62 | runs-on: ${{ matrix.os }} 63 | steps: 64 | - name: Checkout current branch (full) 65 | uses: actions/checkout@v6 66 | with: 67 | fetch-depth: 0 68 | 69 | - name: Setup Java (temurin@11) 70 | if: matrix.java == 'temurin@11' 71 | uses: actions/setup-java@v5 72 | with: 73 | distribution: temurin 74 | java-version: 11 75 | cache: sbt 76 | 77 | - name: Setup sbt 78 | uses: sbt/setup-sbt@v1 79 | 80 | - env: 81 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 82 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 83 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 84 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 85 | run: sbt ci-release 86 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | permissions: 13 | actions: write 14 | 15 | jobs: 16 | delete-artifacts: 17 | name: Delete Artifacts 18 | runs-on: ubuntu-latest 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | steps: 22 | - name: Delete artifacts 23 | shell: bash {0} 24 | run: | 25 | # Customize those three lines with your repository and credentials: 26 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 27 | 28 | # A shortcut to call GitHub API. 29 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 30 | 31 | # A temporary file which receives HTTP response headers. 32 | TMPFILE=$(mktemp) 33 | 34 | # An associative array, key: artifact name, value: number of artifacts of that name. 35 | declare -A ARTCOUNT 36 | 37 | # Process all artifacts on this repository, loop on returned "pages". 38 | URL=$REPO/actions/artifacts 39 | while [[ -n "$URL" ]]; do 40 | 41 | # Get current page, get response headers in a temporary file. 42 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 43 | 44 | # Get URL of next page. Will be empty if we are at the last page. 45 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 46 | rm -f $TMPFILE 47 | 48 | # Number of artifacts on this page: 49 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 50 | 51 | # Loop on all artifacts on this page. 52 | for ((i=0; $i < $COUNT; i++)); do 53 | 54 | # Get name of artifact and count instances of this name. 55 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 56 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 57 | 58 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 59 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 60 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 61 | ghapi -X DELETE $REPO/actions/artifacts/$id 62 | done 63 | done 64 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/DiffResult.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.utils.TypeName.SomeTypeName 4 | 5 | import scala.collection.immutable.ListMap 6 | 7 | sealed trait DiffResult { 8 | 9 | /** 10 | * Whether this DiffResult was produced from an ignored Differ 11 | * @return 12 | */ 13 | def isIgnored: Boolean 14 | 15 | /** 16 | * Whether this DiffResult is consider "successful". 17 | * If there are any non-ignored differences found, then this should be false 18 | * @return 19 | */ 20 | def isOk: Boolean 21 | 22 | /** 23 | * Whether the input leading to this DiffResult has both sides or just one. 24 | * @return 25 | */ 26 | def pairType: PairType 27 | } 28 | 29 | object DiffResult { 30 | final case class ListResult( 31 | typeName: SomeTypeName, 32 | items: Vector[DiffResult], 33 | pairType: PairType, 34 | isIgnored: Boolean, 35 | isOk: Boolean, 36 | ) extends DiffResult 37 | 38 | final case class RecordResult( 39 | typeName: SomeTypeName, 40 | fields: ListMap[String, DiffResult], 41 | pairType: PairType, 42 | isIgnored: Boolean, 43 | isOk: Boolean, 44 | ) extends DiffResult 45 | 46 | final case class MapResult( 47 | typeName: SomeTypeName, 48 | entries: Vector[MapResult.Entry], 49 | pairType: PairType, 50 | isIgnored: Boolean, 51 | isOk: Boolean, 52 | ) extends DiffResult 53 | 54 | object MapResult { 55 | final case class Entry(key: String, value: DiffResult) 56 | } 57 | 58 | final case class MismatchTypeResult( 59 | obtained: DiffResult, 60 | obtainedTypeName: SomeTypeName, 61 | expected: DiffResult, 62 | expectedTypeName: SomeTypeName, 63 | pairType: PairType, 64 | isIgnored: Boolean, 65 | ) extends DiffResult { 66 | override def isOk: Boolean = isIgnored 67 | } 68 | 69 | sealed trait ValueResult extends DiffResult 70 | 71 | object ValueResult { 72 | final case class Both(obtained: String, expected: String, isSame: Boolean, isIgnored: Boolean) extends ValueResult { 73 | override def pairType: PairType = PairType.Both 74 | override def isOk: Boolean = isIgnored || isSame 75 | } 76 | final case class ObtainedOnly(obtained: String, isIgnored: Boolean) extends ValueResult { 77 | override def pairType: PairType = PairType.ObtainedOnly 78 | override def isOk: Boolean = false 79 | } 80 | final case class ExpectedOnly(expected: String, isIgnored: Boolean) extends ValueResult { 81 | override def pairType: PairType = PairType.ExpectedOnly 82 | override def isOk: Boolean = false 83 | } 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala-3/difflicious/ScalaVersionDependentTests.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.testtypes.* 4 | import difflicious.implicits.* 5 | import difflicious.testutils.* 6 | 7 | trait ScalaVersionDependentTests: 8 | this: munit.FunSuite => 9 | 10 | test("configure path's subType call errors when super type isn't sealed") { 11 | val compileError = compileErrors("Differ[List[OpenSuperType]].ignoreAt(_.each.subType[OpenSub])") 12 | val firstLine = compileError.linesIterator.toList.head 13 | assertEquals( 14 | firstLine, 15 | "error: subType requires that the super type be a sealed trait (enum), and the subtype being a direct children of the super type.", 16 | ) 17 | } 18 | 19 | test("Derived Enum: isOk == true if two values are equal") { 20 | assertOkIfValuesEqualProp(MyEnum.given_Differ_MyEnum) 21 | } 22 | 23 | test("Derived Enum: isOk == false if two values are NOT equal") { 24 | assertNotOkIfNotEqualProp(MyEnum.given_Differ_MyEnum) 25 | } 26 | 27 | test("Derived Enum: isOk always true if differ is marked ignored") { 28 | assertIsOkIfIgnoredProp(MyEnum.given_Differ_MyEnum) 29 | } 30 | 31 | test(".subType[MyEnum.XY] in path expression works") { 32 | assertConsoleDiffOutput( 33 | Differ[List[MyEnum]].ignoreAt(_.each.subType[MyEnum.XY].i), 34 | List( 35 | MyEnum.XY(1, "2"), 36 | ), 37 | List( 38 | MyEnum.XY(5, "6"), 39 | ), 40 | s"""List( 41 | | XY( 42 | | i: $grayIgnoredStr, 43 | | j: ${R}"2"${X} -> ${G}"6"${X} 44 | | ) 45 | |)""".stripMargin, 46 | ) 47 | } 48 | 49 | test(".subType[XY] in path expression works") { 50 | import MyEnum.XY 51 | assertConsoleDiffOutput( 52 | Differ[List[MyEnum]].ignoreAt(_.each.subType[XY].i), 53 | List( 54 | MyEnum.XY(1, "2"), 55 | ), 56 | List( 57 | MyEnum.XY(5, "6"), 58 | ), 59 | s"""List( 60 | | XY( 61 | | i: $grayIgnoredStr, 62 | | j: ${R}"2"${X} -> ${G}"6"${X} 63 | | ) 64 | |)""".stripMargin, 65 | ) 66 | } 67 | 68 | test(".subType[TypeAlias] in path expression works") { 69 | import MyEnum.XY 70 | type Alias = XY 71 | assertConsoleDiffOutput( 72 | Differ[List[MyEnum]].ignoreAt(_.each.subType[Alias].i), 73 | List( 74 | MyEnum.XY(1, "2"), 75 | ), 76 | List( 77 | MyEnum.XY(5, "6"), 78 | ), 79 | s"""List( 80 | | XY( 81 | | i: $grayIgnoredStr, 82 | | j: ${R}"2"${X} -> ${G}"6"${X} 83 | | ) 84 | |)""".stripMargin, 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /project/Build.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import sbt.Keys._ 3 | import com.jsuereth.sbtpgp.PgpKeys._ 4 | import sbt.internal.LogManager 5 | import sbt.internal.util.BufferedAppender 6 | import java.io.PrintStream 7 | import sbt.internal.ProjectMatrix 8 | import sbtprojectmatrix.ProjectMatrixPlugin.autoImport.virtualAxes 9 | 10 | object Build { 11 | 12 | val Scala213 = "2.13.18" 13 | val Scala3 = "3.3.7" 14 | 15 | // copied from: https://github.com/disneystreaming/smithy4s/blob/21a6fb04ab3485c0a4b40fe205a628c6f4750813/project/Smithy4sBuildPlugin.scala#L508 16 | def createBuildCommands(projects: Seq[ProjectReference]) = { 17 | case class Doublet(scala: String, platform: String) 18 | 19 | val scala3Suffix = VirtualAxis.scalaABIVersion(Scala3).idSuffix 20 | val scala213Suffix = VirtualAxis.scalaABIVersion(Scala213).idSuffix 21 | val jsSuffix = VirtualAxis.js.idSuffix 22 | val nativeSuffix = VirtualAxis.native.idSuffix 23 | 24 | val all: List[(Doublet, Seq[String])] = 25 | projects 26 | .collect { case lp: LocalProject => 27 | var projectId = lp.project 28 | 29 | val scalaAxis = 30 | if (projectId.endsWith(scala3Suffix)) { 31 | projectId = projectId.dropRight(scala3Suffix.length) 32 | "3_0" 33 | } else "2_13" 34 | 35 | val platformAxis = 36 | if (projectId.endsWith(jsSuffix)) { 37 | projectId = projectId.dropRight(jsSuffix.length) 38 | "js" 39 | } else if (projectId.endsWith(nativeSuffix)) { 40 | projectId = projectId.dropRight(nativeSuffix.length) 41 | "native" 42 | } else { 43 | "jvm" 44 | } 45 | 46 | Doublet(scalaAxis, platformAxis) -> lp.project 47 | } 48 | .groupBy(_._1) 49 | .mapValues(_.map(_._2)) 50 | .toList 51 | 52 | // some commands, like test and compile, are setup for all modules 53 | val any = (t: Doublet) => true 54 | // things like scalafix and scalafmt are only enabled on jvm 2.13 projects 55 | val jvm2_13 = (t: Doublet) => t.scala == "2_13" && t.platform == "jvm" 56 | 57 | val jvm = (t: Doublet) => t.platform == "jvm" 58 | 59 | val desiredCommands: Map[String, (String, Doublet => Boolean)] = Map( 60 | "test" -> ("test", any), 61 | "compile" -> ("compile", any), 62 | "publishLocal" -> ("publishLocal", any), 63 | ) 64 | 65 | val cmds = all.flatMap { case (doublet, projects) => 66 | desiredCommands.filter(_._2._2(doublet)).map { case (name, (cmd, _)) => 67 | Command.command( 68 | s"${name}_${doublet.scala}_${doublet.platform}", 69 | ) { state => 70 | projects.foldLeft(state) { case (st, proj) => 71 | s"$proj/$cmd" :: st 72 | } 73 | } 74 | } 75 | } 76 | 77 | cmds 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala/difflicious/testutils/package.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.DiffResultPrinter.consoleOutput 4 | import munit.Assertions.assertEquals 5 | import org.scalacheck.Prop.{forAll, propBoolean} 6 | import org.scalacheck.{Arbitrary, Prop} 7 | import difflicious.internal.EitherGetSyntax._ 8 | 9 | package object testutils { 10 | 11 | val R = "\u001b[31m" // obtained (red) 12 | val G = "\u001b[32m" // expected (green) 13 | val I = "\u001b[90m" // ignore (dark grey) 14 | val X = "\u001b[39m" // terminal color reset 15 | val grayIgnoredStr = s"$I[IGNORED]$X" 16 | // Sometimes the [IGNORE] field exist in a obtained/expected-only object so it won't be colored 17 | val justIgnoredStr = s"[IGNORED]" 18 | 19 | def assertOkIfValuesEqualProp[A: Arbitrary](differ: Differ[A]): Prop = { 20 | forAll { (l: A) => 21 | val res = differ.diff(l, l) 22 | res.isOk 23 | } 24 | } 25 | 26 | def assertNotOkIfNotEqualProp[A: Arbitrary](differ: Differ[A]): Prop = { 27 | forAll { (l: A, r: A) => 28 | (l != r) ==> { 29 | val res = differ.diff(l, r) 30 | !res.isOk 31 | } 32 | } 33 | } 34 | 35 | def assertIsOkIfIgnoredProp[A: Arbitrary](differ: Differ[A]): Prop = { 36 | val differIgnored = differ.configureRaw(ConfigurePath.current, ConfigureOp.ignore).unsafeGet 37 | val differUnignored = differIgnored.configureRaw(ConfigurePath.current, ConfigureOp.unignore).unsafeGet 38 | forAll { (l: A, r: A) => 39 | val ignoredResult = differIgnored.diff(l, r) 40 | assert(ignoredResult.isOk) 41 | assertDiffResultRender( 42 | ignoredResult, 43 | expectedOutputStr = grayIgnoredStr, 44 | ) 45 | if (l == r) differUnignored.diff(l, r).isOk 46 | else !differUnignored.diff(l, r).isOk 47 | } 48 | } 49 | 50 | def assertDiffResultRender( 51 | res: DiffResult, 52 | expectedOutputStr: String, 53 | ): Unit = { 54 | 55 | // Reverse "recolor each line" difflicious.DiffResultPrinter.colorOnMatchType 56 | // to make test expectations easier to read and write 57 | def removeMultilineRecoloring(str: String): String = { 58 | val k = s"$X\n $R" 59 | val j = s"$X\n $G" 60 | val replaced = str.replace(k, "\n ").replace(j, "\n ") 61 | replaced 62 | } 63 | 64 | val obtainedOutputStr = removeMultilineRecoloring(consoleOutput(res, 0).render) 65 | 66 | if (obtainedOutputStr != expectedOutputStr) { 67 | println("=== Obtained Output === ") 68 | println(obtainedOutputStr) 69 | println("=== Expected Output === ") 70 | println(expectedOutputStr) 71 | assertEquals(obtainedOutputStr, expectedOutputStr) 72 | } else () 73 | } 74 | 75 | def assertConsoleDiffOutput[A]( 76 | differ: Differ[A], 77 | obtained: A, 78 | expected: A, 79 | expectedOutputStr: String, 80 | ): Unit = { 81 | val res = differ.diff(obtained, expected) 82 | assertDiffResultRender(res, expectedOutputStr) 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/differ/RecordDiffer.scala: -------------------------------------------------------------------------------- 1 | package difflicious.differ 2 | 3 | import scala.collection.immutable.ListMap 4 | import difflicious._ 5 | import difflicious.utils.TypeName.SomeTypeName 6 | 7 | /** 8 | * A differ for a record-like data structure such as tuple or case classes. 9 | */ 10 | final class RecordDiffer[T]( 11 | fieldDiffers: ListMap[String, (T => Any, Differ[Any])], 12 | isIgnored: Boolean, 13 | typeName: SomeTypeName, 14 | ) extends Differ[T] { 15 | override type R = DiffResult.RecordResult 16 | 17 | override def diff(inputs: DiffInput[T]): R = inputs match { 18 | case DiffInput.Both(obtained, expected) => { 19 | val diffResults = fieldDiffers 20 | .map { 21 | case (fieldName, (getter, differ)) => 22 | val diffResult = differ.diff(getter(obtained), getter(expected)) 23 | 24 | fieldName -> diffResult 25 | } 26 | .to(ListMap) 27 | DiffResult 28 | .RecordResult( 29 | typeName = typeName, 30 | fields = diffResults, 31 | pairType = PairType.Both, 32 | isIgnored = isIgnored, 33 | isOk = isIgnored || diffResults.values.forall(_.isOk), 34 | ) 35 | } 36 | case DiffInput.ObtainedOnly(value) => { 37 | val diffResults = fieldDiffers 38 | .map { 39 | case (fieldName, (getter, differ)) => 40 | val diffResult = differ.diff(DiffInput.ObtainedOnly(getter(value))) 41 | 42 | fieldName -> diffResult 43 | } 44 | .to(ListMap) 45 | DiffResult 46 | .RecordResult( 47 | typeName = typeName, 48 | fields = diffResults, 49 | pairType = PairType.ObtainedOnly, 50 | isIgnored = isIgnored, 51 | isOk = isIgnored, 52 | ) 53 | } 54 | case DiffInput.ExpectedOnly(expected) => { 55 | val diffResults = fieldDiffers 56 | .map { 57 | case (fieldName, (getter, differ)) => 58 | val diffResult = differ.diff(DiffInput.ExpectedOnly(getter(expected))) 59 | 60 | fieldName -> diffResult 61 | } 62 | .to(ListMap) 63 | DiffResult 64 | .RecordResult( 65 | typeName = typeName, 66 | fields = diffResults, 67 | pairType = PairType.ExpectedOnly, 68 | isIgnored = isIgnored, 69 | isOk = isIgnored, 70 | ) 71 | } 72 | } 73 | 74 | override def configureIgnored(newIgnored: Boolean): Differ[T] = 75 | new RecordDiffer[T](fieldDiffers = fieldDiffers, isIgnored = newIgnored, typeName = typeName) 76 | 77 | override def configurePath( 78 | step: String, 79 | nextPath: ConfigurePath, 80 | op: ConfigureOp, 81 | ): Either[ConfigureError, Differ[T]] = 82 | fieldDiffers 83 | .get(step) 84 | .toRight(ConfigureError.NonExistentField(nextPath, "RecordDiffer")) 85 | .flatMap { 86 | case (getter, fieldDiffer) => 87 | fieldDiffer.configureRaw(nextPath, op).map { newFieldDiffer => 88 | new RecordDiffer[T]( 89 | fieldDiffers = fieldDiffers.updated(step, (getter, newFieldDiffer)), 90 | isIgnored = isIgnored, 91 | typeName = typeName, 92 | ) 93 | } 94 | } 95 | override def configurePairBy(path: ConfigurePath, op: ConfigureOp.PairBy[_]): Either[ConfigureError, Differ[T]] = 96 | Left(ConfigureError.InvalidConfigureOp(path, op, "RecordDiffer")) 97 | } 98 | -------------------------------------------------------------------------------- /docs/docs/docs/BestPracticeAndFAQ.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: docs 3 | title: "Best Practices and Frequently Asked Questions" 4 | permalink: docs/best-practices-and-faq 5 | --- 6 | 7 | # Best Practices 8 | 9 | ## Managing `Differ` instances 10 | 11 | Tests are often the last check for the correctness of your program before it gets deployed, so we need to 12 | 13 | Here are some tips to help you best manage `Differ` instances when using difflicious for testing: 14 | 15 | * Only put unmodified derived Differ instances in the implicit scope 16 | * This avoids the scenario where a modified Differ is pulled in accidentally during derivation, which can results in 17 | passing tests that otherwise should fail. 18 | * If you need a modified Differ instance to be used in a derivation, scope it locally 19 | 20 | ```scala 21 | object DifferInstances { 22 | implicit val personDiffer: Differ[Person] = Differ.derived[Person] 23 | 24 | val personByNameSeqDiffer: Differ[List[Person]] = Differ[List[Person]].pairBy(_.name) 25 | } 26 | 27 | // ...Somewhere else 28 | val schoolDiffer: Differ[School] = { 29 | implicit val personByNameSeqDiffer: Differ[List[Person]] = DifferInstances.personByNameSeqDiffer 30 | Differ.derived[School] 31 | } 32 | ``` 33 | 34 | # Frequently Asked Questions 35 | 36 | ## Where is fully automatic derivation for `Differ`s? 37 | 38 | Fully automatic derivation is strongly discouraged, however it might be convenient in certain debugging use-cases. 39 | 40 | With automatic derivation, the compiler will derive the instances **every time it is needed**. 41 | This very frequently leads to extremely long compile times which isn't worth the few lines of code it saves you. 42 | 43 | To enable auto-derivation add following import: 44 | 45 | for scala 2 46 | ```scala 47 | import difflicious.generic.auto._ 48 | ``` 49 | 50 | for scala 3 51 | ```scala 52 | import difflicious.generic.auto.given 53 | ``` 54 | 55 | ## How is difflicious different from other projects that provides diffs? 56 | 57 | **MUnit**: MUnit's `assertEquals` comes out of the box with diff output for case classes. Users do not need to do anything 58 | to get the diff output due to its simplicity it isn't really configurable. It is a good idea to start with MUnit's `assertEquals` 59 | and only use Difflicious when you need its configurability for more complex assertion failures. 60 | 61 | **DiffX**: DiffX is one of the inspirations of this library and Difflicious aims to support all DiffX features/use cases. 62 | 63 | Feature-wise, difflicious has: 64 | 65 | - Better collection diffing: Difflicious allows you to specify how Seq/Set elements are paired for comparison. 66 | Pairing also allows you compare Seqs order-independently. 67 | - Better configurability: Difflicious takes a more "structured" approach to configurability, where Differ of a complex type 68 | can still have all its underlying Differs tweaked or even replaced (using `replace`). This is handy in some scenarios 69 | where you can reuse existing Differs by "swapping" them in and out of a larger Differ. 70 | 71 | Diffx is no loger actively developed. 72 | 73 | ## How can I provide a Differ for my newtypes / opaque types? 74 | 75 | Many Scala users like to use a wrapper type around primitive types for additional type-safety. 76 | 77 | All `ValueDiffer` has a `contramap` method you can use. 78 | 79 | ```scala mdoc:invisible 80 | import difflicious._ 81 | ``` 82 | 83 | ```scala mdoc:silent 84 | final case class UserId(value: String) 85 | 86 | val userIdDiffer: Differ[UserId] = Differ.stringDiffer.contramap(_.value) 87 | ``` 88 | 89 | Note that the type of Differ.stringDiffer is a `ValueDiffer` (`ValueDiffer` is a subtype of `Differ`) 90 | 91 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/differ/SetDiffer.scala: -------------------------------------------------------------------------------- 1 | package difflicious.differ 2 | 3 | import difflicious.ConfigureOp.PairBy 4 | import difflicious.DiffResult.ListResult 5 | import difflicious.differ.SeqDiffer.diffPairByFunc 6 | import difflicious.utils.TypeName.SomeTypeName 7 | import difflicious.utils.SetLike 8 | import difflicious.{Differ, ConfigureOp, ConfigureError, ConfigurePath, DiffInput, PairType} 9 | 10 | final class SetDiffer[F[_], A]( 11 | isIgnored: Boolean, 12 | itemDiffer: Differ[A], 13 | matchFunc: A => Any, 14 | typeName: SomeTypeName, 15 | asSet: SetLike[F], 16 | ) extends Differ[F[A]] { 17 | override type R = ListResult 18 | 19 | override def diff(inputs: DiffInput[F[A]]): R = inputs.map(asSet.asSet) match { 20 | case DiffInput.ObtainedOnly(actual) => 21 | ListResult( 22 | typeName = typeName, 23 | actual.toVector.map { a => 24 | itemDiffer.diff(DiffInput.ObtainedOnly(a)) 25 | }, 26 | PairType.ObtainedOnly, 27 | isIgnored = isIgnored, 28 | isOk = isIgnored, 29 | ) 30 | case DiffInput.ExpectedOnly(expected) => 31 | ListResult( 32 | typeName = typeName, 33 | items = expected.toVector.map { e => 34 | itemDiffer.diff(DiffInput.ExpectedOnly(e)) 35 | }, 36 | pairType = PairType.ExpectedOnly, 37 | isIgnored = isIgnored, 38 | isOk = isIgnored, 39 | ) 40 | case DiffInput.Both(obtained, expected) => { 41 | val (results, overallIsSame) = diffPairByFunc(obtained.toSeq, expected.toSeq, matchFunc, itemDiffer) 42 | ListResult( 43 | typeName = typeName, 44 | items = results, 45 | pairType = PairType.Both, 46 | isIgnored = isIgnored, 47 | isOk = isIgnored || overallIsSame, 48 | ) 49 | } 50 | } 51 | 52 | override def configureIgnored(newIgnored: Boolean): Differ[F[A]] = 53 | new SetDiffer[F, A]( 54 | isIgnored = newIgnored, 55 | itemDiffer = itemDiffer, 56 | matchFunc = matchFunc, 57 | typeName = typeName, 58 | asSet = asSet, 59 | ) 60 | 61 | override def configurePath( 62 | step: String, 63 | nextPath: ConfigurePath, 64 | op: ConfigureOp, 65 | ): Either[ConfigureError, Differ[F[A]]] = 66 | if (step == "each") { 67 | itemDiffer.configureRaw(nextPath, op).map { updatedItemDiffer => 68 | new SetDiffer[F, A]( 69 | isIgnored = isIgnored, 70 | itemDiffer = updatedItemDiffer, 71 | matchFunc = matchFunc, 72 | typeName = typeName, 73 | asSet = asSet, 74 | ) 75 | } 76 | } else Left(ConfigureError.NonExistentField(nextPath, "SetDiffer")) 77 | 78 | override def configurePairBy(path: ConfigurePath, op: PairBy[_]): Either[ConfigureError, Differ[F[A]]] = 79 | op match { 80 | case PairBy.Index => Left(ConfigureError.InvalidConfigureOp(path, op, "SetDiffer")) 81 | case m: PairBy.ByFunc[_, _] => 82 | Right( 83 | new SetDiffer[F, A]( 84 | isIgnored = isIgnored, 85 | itemDiffer = itemDiffer, 86 | matchFunc = m.func.asInstanceOf[A => Any], 87 | typeName = typeName, 88 | asSet = asSet, 89 | ), 90 | ) 91 | } 92 | } 93 | 94 | object SetDiffer { 95 | def create[F[_], A]( 96 | itemDiffer: Differ[A], 97 | typeName: SomeTypeName, 98 | asSet: SetLike[F], 99 | ): SetDiffer[F, A] = new SetDiffer[F, A]( 100 | isIgnored = false, 101 | itemDiffer, 102 | matchFunc = identity, 103 | typeName = typeName, 104 | asSet = asSet, 105 | ) 106 | 107 | } 108 | -------------------------------------------------------------------------------- /modules/core/src/main/scala-3/difflicious/internal/ConfigureMethods.scala: -------------------------------------------------------------------------------- 1 | package difflicious.internal 2 | import difflicious.{ConfigurePath, Differ, ConfigureOp} 3 | 4 | import scala.quoted.* 5 | import scala.annotation.tailrec 6 | import difflicious.internal.ConfigureMethodImpls.* 7 | 8 | trait ConfigureMethods[T]: 9 | this: Differ[T] => 10 | 11 | val requiredShapeMsg = "Configure path must have shape like: _.field1.each.field2.subType[ASubClass]" 12 | 13 | inline def ignoreAt[U](inline path: T => U): Differ[T] = 14 | ${ ignoreAt_impl('this, 'path) } 15 | 16 | inline def configure[U](inline path: T => U)(configFunc: Differ[U] => Differ[U]): Differ[T] = 17 | ${ configure_impl('this, 'path, 'configFunc) } 18 | 19 | inline def replace[U](inline path: T => U)(newDiffer: Differ[U]): Differ[T] = 20 | ${ replace_impl('this, 'path, 'newDiffer) } 21 | 22 | private[difflicious] object ConfigureMethodImpls: 23 | 24 | def ignoreAt_impl[T: Type, U](differ: Expr[Differ[T]], path: Expr[T => U])(using Quotes): Expr[Differ[T]] = { 25 | '{ 26 | (${ differ } 27 | .configureRaw(ConfigurePath.fromPath(${ collectPathElements(path) }), ConfigureOp.ignore)) match { 28 | case Right(d) => d 29 | case Left(e) => throw e 30 | } 31 | } 32 | } 33 | 34 | def configure_impl[T: Type, U: Type]( 35 | differ: Expr[Differ[T]], 36 | path: Expr[T => U], 37 | configFunc: Expr[Differ[U] => Differ[U]], 38 | )(using 39 | Quotes, 40 | ): Expr[Differ[T]] = 41 | '{ 42 | (${ differ } 43 | .configureRaw( 44 | ConfigurePath.fromPath(${ collectPathElements(path) }), 45 | ConfigureOp.TransformDiffer(${ configFunc }), 46 | )) match { 47 | case Right(d) => d 48 | case Left(e) => throw e 49 | } 50 | } 51 | 52 | def replace_impl[T: Type, U: Type]( 53 | differ: Expr[Differ[T]], 54 | path: Expr[T => U], 55 | newDiffer: Expr[Differ[U]], 56 | )(using 57 | Quotes, 58 | ): Expr[Differ[T]] = 59 | '{ 60 | ${ differ } 61 | .configureRaw( 62 | ConfigurePath.fromPath(${ collectPathElements(path) }), 63 | ConfigureOp.TransformDiffer[U](_ => ${ newDiffer }), 64 | ) match { 65 | case Right(d) => d 66 | case Left(e) => throw e 67 | } 68 | } 69 | 70 | def collectPathElements[T, U](pathExpr: Expr[T => U])(using Quotes): Expr[List[String]] = { 71 | import quotes.reflect.* 72 | 73 | // import dotty.tools.dotc.ast.Trees._ 74 | @tailrec 75 | def collectPathElementsLoop(pathAccum: List[String], cur: Term): List[String] = 76 | cur match { 77 | case Select(rest, name) => 78 | collectPathElementsLoop(name.toString :: pathAccum, rest) 79 | case x @ TypeApply(Select(Apply(TypeApply(_, superType :: Nil), rest :: Nil), "subType"), subType :: Nil) => 80 | val typeSym = subType.tpe.dealias.typeSymbol 81 | if superType.symbol.children.contains(subType.tpe.dealias.typeSymbol) then 82 | collectPathElementsLoop(typeSym.name :: pathAccum, rest) 83 | else 84 | report.error( 85 | s"subType requires that the super type be a sealed trait (enum), and the subtype being a direct children of the super type.", 86 | x.asExpr, 87 | ) 88 | List.empty 89 | case Apply(Apply(TypeApply(Ident(name), _), rest :: Nil), _) if name.toString == "toEachableOps" => 90 | collectPathElementsLoop(pathAccum, rest) 91 | case Ident(_) => pathAccum 92 | case _ => { 93 | throw new Exception(cur.show(using Printer.TreeShortCode) ++ "|||" ++ cur.show(using Printer.TreeStructure)) 94 | } 95 | } 96 | 97 | pathExpr.asTerm match { 98 | case Inlined(_, _, Block(List(DefDef(_, _, _, Some(tree))), _)) => 99 | Expr(collectPathElementsLoop(List.empty, tree)) 100 | case _ => 101 | report.error(s"Unexpected path expression. This is a bug: ${pathExpr.asTerm.show(using Printer.TreeStructure)}") 102 | '{ ??? } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /modules/cats/src/main/scala/difflicious/cats/CatsInstances.scala: -------------------------------------------------------------------------------- 1 | package difflicious.cats 2 | 3 | import _root_.cats.data._ 4 | import difflicious.Differ 5 | import difflicious.differ.{ValueDiffer, SeqDiffer, MapDiffer, SetDiffer} 6 | import difflicious.utils._ 7 | 8 | trait CatsInstances { 9 | implicit val nonEmptyMapAsMap: MapLike[NonEmptyMap] = new MapLike[NonEmptyMap] { 10 | override def asMap[A, B](m: NonEmptyMap[A, B]): Map[A, B] = m.toSortedMap 11 | } 12 | 13 | implicit def nonEmptyMapEachable[K]: Eachable[NonEmptyMap[K, *]] = new Eachable[NonEmptyMap[K, *]] {} 14 | 15 | implicit val nonEmptyListAsSeq: SeqLike[NonEmptyList] = new SeqLike[NonEmptyList] { 16 | override def asSeq[A](f: NonEmptyList[A]): Seq[A] = f.toList 17 | } 18 | 19 | implicit val nonEmptyVectorAsSeq: SeqLike[NonEmptyVector] = new SeqLike[NonEmptyVector] { 20 | override def asSeq[A](f: NonEmptyVector[A]): Seq[A] = f.toVector 21 | } 22 | 23 | implicit val chainAsSeq: SeqLike[Chain] = new SeqLike[Chain] { 24 | override def asSeq[A](f: Chain[A]): Seq[A] = f.toVector 25 | } 26 | 27 | implicit val nonEmptyChainAsSeq: SeqLike[NonEmptyChain] = new SeqLike[NonEmptyChain] { 28 | override def asSeq[A](f: NonEmptyChain[A]): Seq[A] = f.toChain.toVector 29 | } 30 | 31 | implicit val nonEmptySetAsSet: SetLike[NonEmptySet] = new SetLike[NonEmptySet] { 32 | override def asSet[A](f: NonEmptySet[A]): Set[A] = f.toSortedSet 33 | } 34 | 35 | implicit def nonEmptyMapDiffer[K, V]( 36 | implicit keyDiffer: ValueDiffer[K], 37 | valueDiffer: Differ[V], 38 | typeName: TypeName[NonEmptyMap[K, V]], 39 | ): MapDiffer[NonEmptyMap, K, V] = new MapDiffer[NonEmptyMap, K, V]( 40 | isIgnored = false, 41 | keyDiffer = keyDiffer, 42 | valueDiffer = valueDiffer, 43 | typeName = typeName.copy(long = "cats.data.NonEmptyMap", short = "NonEmptyMap"), 44 | asMap = nonEmptyMapAsMap, 45 | ) 46 | 47 | implicit def nonEmptyListDiffer[A]( 48 | implicit aDiffer: Differ[A], 49 | typeName: TypeName[NonEmptyList[A]], 50 | ): SeqDiffer[NonEmptyList, A] = { 51 | SeqDiffer.create( 52 | itemDiffer = aDiffer, 53 | typeName = typeName.copy( 54 | long = "cats.data.NonEmptyList", 55 | short = "NonEmptyList", 56 | ), 57 | asSeq = nonEmptyListAsSeq, 58 | ) 59 | } 60 | 61 | implicit def nonEmptyVectorDiffer[A]( 62 | implicit aDiffer: Differ[A], 63 | typeName: TypeName[NonEmptyVector[A]], 64 | ): SeqDiffer[NonEmptyVector, A] = { 65 | SeqDiffer.create( 66 | itemDiffer = aDiffer, 67 | typeName = typeName.copy( 68 | long = "cats.data.NonEmptyVector", 69 | short = "NonEmptyVector", 70 | ), 71 | asSeq = nonEmptyVectorAsSeq, 72 | ) 73 | } 74 | 75 | implicit def nonEmptyChainDiffer[A]( 76 | implicit aDiffer: Differ[A], 77 | typeName: TypeName[NonEmptyChain[A]], 78 | ): SeqDiffer[NonEmptyChain, A] = { 79 | SeqDiffer.create( 80 | itemDiffer = aDiffer, 81 | typeName = typeName.copy( 82 | long = "cats.data.NonEmptyChain", 83 | short = "NonEmptyChain", 84 | ), 85 | asSeq = nonEmptyChainAsSeq, 86 | ) 87 | } 88 | 89 | implicit def nonEmptySetDiffer[A]( 90 | implicit aDiffer: Differ[A], 91 | typeName: TypeName[NonEmptySet[A]], 92 | ): SetDiffer[NonEmptySet, A] = { 93 | SetDiffer.create( 94 | itemDiffer = aDiffer, 95 | typeName = typeName.copy( 96 | long = "cats.data.NonEmptySet", 97 | short = "NonEmptySet", 98 | ), 99 | asSet = nonEmptySetAsSet, 100 | ) 101 | } 102 | 103 | implicit def chainDiffer[A]( 104 | implicit aDiffer: Differ[A], 105 | typeName: TypeName[Chain[A]], 106 | ): SeqDiffer[Chain, A] = { 107 | SeqDiffer.create( 108 | itemDiffer = aDiffer, 109 | typeName = typeName.copy( 110 | long = "cats.data.Chain", 111 | short = "Chain", 112 | ), 113 | asSeq = chainAsSeq, 114 | ) 115 | } 116 | 117 | implicit def validatedDiffer[E: Differ, A: Differ]: Differ[Validated[E, A]] = Differ.derived 118 | 119 | } 120 | 121 | object CatsInstances extends CatsInstances 122 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala/difflicious/testtypes.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import cats.kernel.Order 4 | import org.scalacheck.{Gen, Arbitrary} 5 | import difflicious.differ.{ValueDiffer, EqualsDiffer} 6 | 7 | object testtypes extends ScalaVersionDependentTestTypes { 8 | 9 | // Dummy differ that fails when any of its method is called. For tests where we just need a Differ[T] 10 | def dummyDiffer[T]: Differ[T] = new Differ[T] { 11 | override def diff(inputs: DiffInput[T]): R = sys.error("diff on dummyDiffer") 12 | 13 | override def configureIgnored(newIgnored: Boolean): Differ[T] = 14 | sys.error("dummyDiffer methods should not be called") 15 | 16 | override def configurePath( 17 | step: String, 18 | nextPath: ConfigurePath, 19 | op: ConfigureOp, 20 | ): Either[ConfigureError, Differ[T]] = sys.error("dummyDiffer methods should not be called") 21 | 22 | override def configurePairBy(path: ConfigurePath, op: ConfigureOp.PairBy[_]): Either[ConfigureError, Differ[T]] = 23 | sys.error("dummyDiffer methods should not be called") 24 | 25 | } 26 | 27 | case class HasASeq[A](seq: Seq[A]) 28 | 29 | object HasASeq { 30 | implicit def differ[A](implicit 31 | differ: Differ[Seq[A]], 32 | ): Differ[HasASeq[A]] = { 33 | Differ.derived[HasASeq[A]] 34 | } 35 | } 36 | 37 | case class CC(i: Int, s: String, dd: Double) 38 | 39 | object CC { 40 | implicit val arb: Arbitrary[CC] = Arbitrary(for { 41 | i <- Arbitrary.arbitrary[Int] 42 | s <- Arbitrary.arbitrary[String] 43 | dd <- Arbitrary.arbitrary[Double] 44 | } yield CC(i, s, dd)) 45 | 46 | implicit val differ: Differ[CC] = Differ.derived[CC] 47 | 48 | implicit val order: Order[CC] = Order.by(a => (a.i, a.s, a.dd)) 49 | } 50 | 51 | final case class EqClass(int: Int) 52 | 53 | object EqClass { 54 | implicit val arb: Arbitrary[EqClass] = Arbitrary.apply(Arbitrary.arbitrary[Int].map(EqClass(_))) 55 | 56 | implicit val differ: EqualsDiffer[EqClass] = Differ.useEquals[EqClass](_.toString) 57 | } 58 | 59 | final case class NewInt(int: Int) 60 | 61 | object NewInt { 62 | implicit val arb: Arbitrary[NewInt] = Arbitrary.apply(Arbitrary.arbitrary[Int].map(NewInt(_))) 63 | 64 | implicit val differ: ValueDiffer[NewInt] = Differ.intDiffer.contramap(_.int) 65 | } 66 | 67 | sealed trait Sealed 68 | 69 | object Sealed { 70 | case class Sub1(i: Int) extends Sealed 71 | final case class Sub2(d: Double) extends Sealed 72 | final case class Sub3(list: List[CC]) extends Sealed 73 | final case class `Weird@Sub`(i: Int, `weird@Field`: String) extends Sealed 74 | 75 | implicit val differ: Differ[Sealed] = Differ.derived[Sealed] 76 | 77 | private val genSub1: Gen[Sub1] = Arbitrary.arbitrary[Int].map(Sub1.apply) 78 | private val genSub2: Gen[Sub2] = Arbitrary.arbitrary[Double].map(Sub2.apply) 79 | private val genSub3: Gen[Sub3] = Arbitrary.arbitrary[List[CC]].map(Sub3.apply) 80 | 81 | implicit val arb: Arbitrary[Sealed] = Arbitrary( 82 | Gen.oneOf( 83 | genSub1, 84 | genSub2, 85 | genSub3, 86 | ), 87 | ) 88 | } 89 | 90 | sealed trait SealedWithCustom 91 | 92 | object SealedWithCustom { 93 | case class Custom(i: Int) extends SealedWithCustom 94 | object Custom { 95 | implicit val differ: Differ[Custom] = Differ.derived[Custom].ignoreAt(_.i) 96 | } 97 | case class Normal(i: Int) extends SealedWithCustom 98 | 99 | implicit val differ: Differ[SealedWithCustom] = Differ.derived[SealedWithCustom] 100 | } 101 | 102 | final case class MapKey(a: Int, b: String) 103 | 104 | object MapKey { 105 | implicit val differ: ValueDiffer[MapKey] = Differ.useEquals(_.toString) 106 | 107 | implicit val arb: Arbitrary[MapKey] = Arbitrary(for { 108 | a <- Arbitrary.arbitrary[Int] 109 | b <- Arbitrary.arbitrary[String] 110 | } yield MapKey(a, b)) 111 | 112 | implicit val order: Order[MapKey] = Order.by(mk => (mk.a, mk.b)) 113 | } 114 | 115 | trait OpenSuperType 116 | 117 | object OpenSuperType { 118 | implicit val differ: Differ[OpenSuperType] = dummyDiffer[OpenSuperType] 119 | } 120 | 121 | final case class OpenSub(i: Int) extends OpenSuperType 122 | 123 | case class AlwaysIgnoreClass(i: Int) 124 | 125 | object AlwaysIgnoreClass { 126 | implicit val differ: AlwaysIgnoreDiffer[AlwaysIgnoreClass] = Differ.alwaysIgnore 127 | } 128 | 129 | } 130 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/differ/MapDiffer.scala: -------------------------------------------------------------------------------- 1 | package difflicious.differ 2 | 3 | import difflicious.DiffResult.{ValueResult, MapResult} 4 | 5 | import scala.collection.mutable 6 | import difflicious.ConfigureOp.PairBy 7 | import difflicious.differ.MapDiffer.mapKeyToString 8 | import difflicious.utils.TypeName.SomeTypeName 9 | import difflicious.{Differ, DiffResult, ConfigureOp, ConfigureError, ConfigurePath, DiffInput, PairType} 10 | import difflicious.utils.MapLike 11 | 12 | class MapDiffer[M[_, _], K, V]( 13 | isIgnored: Boolean, 14 | keyDiffer: ValueDiffer[K], 15 | valueDiffer: Differ[V], 16 | typeName: SomeTypeName, 17 | asMap: MapLike[M], 18 | ) extends Differ[M[K, V]] { 19 | override type R = MapResult 20 | 21 | override def diff(inputs: DiffInput[M[K, V]]): R = inputs.map(asMap.asMap) match { 22 | case DiffInput.Both(obtained, expected) => 23 | val obtainedOnly = mutable.ArrayBuffer.empty[MapResult.Entry] 24 | val both = mutable.ArrayBuffer.empty[MapResult.Entry] 25 | val expectedOnly = mutable.ArrayBuffer.empty[MapResult.Entry] 26 | obtained.foreach { 27 | case (k, actualV) => 28 | expected.get(k) match { 29 | case Some(expectedV) => 30 | both += MapResult.Entry( 31 | mapKeyToString(k, keyDiffer), 32 | valueDiffer.diff(actualV, expectedV), 33 | ) 34 | case None => 35 | obtainedOnly += MapResult.Entry( 36 | mapKeyToString(k, keyDiffer), 37 | valueDiffer.diff(DiffInput.ObtainedOnly(actualV)), 38 | ) 39 | } 40 | } 41 | expected.foreach { 42 | case (k, expectedV) => 43 | if (obtained.contains(k)) { 44 | // Do nothing, already compared when iterating through obtained 45 | } else { 46 | expectedOnly += MapResult.Entry( 47 | mapKeyToString(k, keyDiffer), 48 | valueDiffer.diff(DiffInput.ExpectedOnly(expectedV)), 49 | ) 50 | } 51 | } 52 | MapResult( 53 | typeName = typeName, 54 | (obtainedOnly ++ both ++ expectedOnly).toVector, 55 | PairType.Both, 56 | isIgnored = isIgnored, 57 | isOk = isIgnored || obtainedOnly.isEmpty && expectedOnly.isEmpty && both.forall(_.value.isOk), 58 | ) 59 | case DiffInput.ObtainedOnly(obtained) => 60 | DiffResult.MapResult( 61 | typeName = typeName, 62 | entries = obtained.map { 63 | case (k, v) => 64 | MapResult.Entry(mapKeyToString(k, keyDiffer), valueDiffer.diff(DiffInput.ObtainedOnly(v))) 65 | }.toVector, 66 | pairType = PairType.ObtainedOnly, 67 | isIgnored = isIgnored, 68 | isOk = isIgnored, 69 | ) 70 | case DiffInput.ExpectedOnly(expected) => 71 | DiffResult.MapResult( 72 | typeName = typeName, 73 | entries = expected.map { 74 | case (k, v) => 75 | MapResult.Entry(mapKeyToString(k, keyDiffer), valueDiffer.diff(DiffInput.ExpectedOnly(v))) 76 | }.toVector, 77 | pairType = PairType.ExpectedOnly, 78 | isIgnored = isIgnored, 79 | isOk = isIgnored, 80 | ) 81 | } 82 | 83 | override def configureIgnored(newIgnored: Boolean): Differ[M[K, V]] = { 84 | new MapDiffer[M, K, V]( 85 | isIgnored = newIgnored, 86 | keyDiffer = keyDiffer, 87 | valueDiffer = valueDiffer, 88 | typeName = typeName, 89 | asMap = asMap, 90 | ) 91 | } 92 | 93 | override def configurePath( 94 | step: String, 95 | nextPath: ConfigurePath, 96 | op: ConfigureOp, 97 | ): Either[ConfigureError, Differ[M[K, V]]] = { 98 | if (step == "each") { 99 | valueDiffer.configureRaw(nextPath, op).map { newValueDiffer => 100 | new MapDiffer[M, K, V]( 101 | isIgnored = isIgnored, 102 | keyDiffer = keyDiffer, 103 | valueDiffer = newValueDiffer, 104 | typeName = typeName, 105 | asMap = asMap, 106 | ) 107 | } 108 | } else 109 | Left(ConfigureError.NonExistentField(path = nextPath, "MapDiffer")) 110 | } 111 | 112 | override def configurePairBy(path: ConfigurePath, op: PairBy[_]): Either[ConfigureError, Differ[M[K, V]]] = 113 | Left(ConfigureError.InvalidConfigureOp(path, op, "MapDiffer")) 114 | 115 | } 116 | 117 | object MapDiffer { 118 | 119 | private[MapDiffer] def mapKeyToString[T](k: T, keyDiffer: ValueDiffer[T]): String = { 120 | keyDiffer.diff(DiffInput.ObtainedOnly(k)) match { 121 | case r: ValueResult.ObtainedOnly => r.obtained 122 | // $COVERAGE-OFF$ 123 | case r: ValueResult.Both => r.obtained 124 | case r: ValueResult.ExpectedOnly => r.expected 125 | // $COVERAGE-ON$ 126 | } 127 | } 128 | 129 | } 130 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/Differ.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | import difflicious.ConfigureOp.PairBy 3 | import difflicious.differ._ 4 | import difflicious.internal.ConfigureMethods 5 | import difflicious.utils.{TypeName, MapLike, SetLike, SeqLike} 6 | 7 | trait Differ[T] extends ConfigureMethods[T] { 8 | type R <: DiffResult 9 | 10 | def diff(inputs: DiffInput[T]): R 11 | 12 | final def diff(obtained: T, expected: T): R = diff(DiffInput.Both(obtained, expected)) 13 | 14 | /** 15 | * Attempt to change the configuration of this Differ. 16 | * If successful, a new differ with the updated configuration will be returned. 17 | * 18 | * The configuration change can fail due to 19 | * - bad "path" that does not match the internal structure of the Differ 20 | * - The path resolved correctly, but the configuration update operation cannot be applied for that part of the Differ 21 | * (e.g. wrong type or wrong operation) 22 | * 23 | * @param path The path to traverse to the sub-Differ 24 | * @param operation The configuration change operation you want to perform on the target sub-Differ 25 | */ 26 | final def configureRaw(path: ConfigurePath, operation: ConfigureOp): Either[ConfigureError, Differ[T]] = { 27 | (path.unresolvedSteps, operation) match { 28 | case (step :: tail, op) => configurePath(step, ConfigurePath(path.resolvedSteps :+ step, tail), op) 29 | case (Nil, ConfigureOp.SetIgnored(newIgnored)) => Right(configureIgnored(newIgnored)) 30 | case (Nil, pairByOp: ConfigureOp.PairBy[_]) => configurePairBy(path, pairByOp) 31 | case (Nil, op: ConfigureOp.TransformDiffer[_]) => Right(configureTransform(op)) 32 | } 33 | } 34 | 35 | def ignore: Differ[T] = configureIgnored(true) 36 | def unignore: Differ[T] = configureIgnored(false) 37 | 38 | protected def configureIgnored(newIgnored: Boolean): Differ[T] 39 | 40 | protected def configurePath(step: String, nextPath: ConfigurePath, op: ConfigureOp): Either[ConfigureError, Differ[T]] 41 | 42 | protected def configurePairBy(path: ConfigurePath, op: PairBy[_]): Either[ConfigureError, Differ[T]] 43 | 44 | final private def configureTransform( 45 | op: ConfigureOp.TransformDiffer[_], 46 | ): Differ[T] = { 47 | op.unsafeCastFunc[T].apply(this) 48 | } 49 | } 50 | 51 | object Differ extends DifferTupleInstances with DifferGen with DifferTimeInstances { 52 | 53 | def apply[A](implicit differ: Differ[A]): Differ[A] = differ 54 | 55 | def useEquals[T](valueToString: T => String): EqualsDiffer[T] = 56 | new EqualsDiffer[T](isIgnored = false, valueToString = valueToString) 57 | 58 | /** A Differ that always return an Ignored result. Useful when you can't really diff something */ 59 | def alwaysIgnore[T]: AlwaysIgnoreDiffer[T] = new AlwaysIgnoreDiffer[T] 60 | 61 | implicit val stringDiffer: ValueDiffer[String] = useEquals[String](str => s""""$str"""") 62 | implicit val charDiffer: ValueDiffer[Char] = useEquals[Char](c => s"'$c'") 63 | implicit val booleanDiffer: ValueDiffer[Boolean] = useEquals[Boolean](_.toString) 64 | 65 | implicit val intDiffer: NumericDiffer[Int] = NumericDiffer.make[Int] 66 | implicit val doubleDiffer: NumericDiffer[Double] = NumericDiffer.make[Double] 67 | implicit val shortDiffer: NumericDiffer[Short] = NumericDiffer.make[Short] 68 | implicit val byteDiffer: NumericDiffer[Byte] = NumericDiffer.make[Byte] 69 | implicit val longDiffer: NumericDiffer[Long] = NumericDiffer.make[Long] 70 | implicit val bigDecimalDiffer: NumericDiffer[BigDecimal] = NumericDiffer.make[BigDecimal] 71 | implicit val bigIntDiffer: NumericDiffer[BigInt] = NumericDiffer.make[BigInt] 72 | 73 | implicit def optionDiffer[T: Differ]: Differ[Option[T]] = derived[Option[T]] 74 | implicit def eitherDiffer[A: Differ, B: Differ]: Differ[Either[A, B]] = derived[Either[A, B]] 75 | 76 | implicit def mapDiffer[M[_, _], K, V]( 77 | implicit keyDiffer: ValueDiffer[K], 78 | valueDiffer: Differ[V], 79 | typeName: TypeName[M[K, V]], 80 | asMap: MapLike[M], 81 | ): MapDiffer[M, K, V] = { 82 | new MapDiffer( 83 | isIgnored = false, 84 | keyDiffer = keyDiffer, 85 | valueDiffer = valueDiffer, 86 | typeName = typeName, 87 | asMap = asMap, 88 | ) 89 | } 90 | 91 | implicit def seqDiffer[F[_], A]( 92 | implicit itemDiffer: Differ[A], 93 | typeName: TypeName[F[A]], 94 | asSeq: SeqLike[F], 95 | ): SeqDiffer[F, A] = { 96 | SeqDiffer.create( 97 | itemDiffer = itemDiffer, 98 | typeName = typeName, 99 | asSeq = asSeq, 100 | ) 101 | } 102 | 103 | implicit def setDiffer[F[_], A]( 104 | implicit itemDiffer: Differ[A], 105 | typeName: TypeName[F[A]], 106 | asSet: SetLike[F], 107 | ): SetDiffer[F, A] = { 108 | SetDiffer.create[F, A]( 109 | itemDiffer = itemDiffer, 110 | typeName = typeName, 111 | asSet = asSet, 112 | ) 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /modules/core/src/main/scala-2.13/difflicious/internal/ConfigureMethods.scala: -------------------------------------------------------------------------------- 1 | package difflicious.internal 2 | 3 | import difflicious.Differ 4 | 5 | import scala.collection.mutable 6 | import scala.annotation.{nowarn, tailrec} 7 | import scala.reflect.macros.blackbox 8 | 9 | trait ConfigureMethods[T] { this: Differ[T] => 10 | 11 | def ignoreAt[U](path: T => U): Differ[T] = macro ConfigureMacro.ignoreAt_impl[T, U] 12 | 13 | def configure[U](path: T => U)(configFunc: Differ[U] => Differ[U]): Differ[T] = 14 | macro ConfigureMacro.configure_impl[T, U] 15 | 16 | def replace[U](path: T => U)(newDiffer: Differ[U]): Differ[T] = 17 | macro ConfigureMacro.replace_impl[T, U] 18 | } 19 | 20 | // Implementation inspired by quicklen's path macro. 21 | // See https://github.com/softwaremill/quicklens/blob/c2fd335b80f3d4d55a76d146d8308d95575dd749/quicklens/src/main/scala-2/com/softwaremill/quicklens/QuicklensMacros.scala 22 | object ConfigureMacro { 23 | 24 | val requiredShapeMsg = "Configure path must have shape like: _.field1.each.field2.subType[ASubClass]" 25 | 26 | def configure_impl[T, U](c: blackbox.Context)( 27 | path: c.Expr[T => U], 28 | )( 29 | configFunc: c.Expr[Differ[U] => Differ[U]], 30 | ): c.Tree = { 31 | import c.universe._ 32 | val opTree = q"_root_.difflicious.ConfigureOp.TransformDiffer($configFunc)" 33 | toConfigureRawCall(c)(path, opTree) 34 | } 35 | 36 | def replace_impl[T, U](c: blackbox.Context)( 37 | path: c.Expr[T => U], 38 | )( 39 | newDiffer: c.Expr[Differ[U]], 40 | )(implicit uTypeTag: c.WeakTypeTag[U]): c.Tree = { 41 | import c.universe._ 42 | val opTree = 43 | q"_root_.difflicious.ConfigureOp.TransformDiffer[${uTypeTag.tpe}](unused => { val _ = unused; $newDiffer })" 44 | toConfigureRawCall(c)(path, opTree) 45 | } 46 | 47 | def ignoreAt_impl[T: c.WeakTypeTag, U: c.WeakTypeTag]( 48 | c: blackbox.Context, 49 | )(path: c.Expr[T => U]): c.Tree = { 50 | import c.universe._ 51 | val configureOpTree = q"_root_.difflicious.ConfigureOp.ignore" 52 | toConfigureRawCall(c)(path, configureOpTree) 53 | } 54 | 55 | @nowarn("msg=.*never used.*") 56 | def toConfigureRawCall[T: c.WeakTypeTag, U: c.WeakTypeTag]( 57 | c: blackbox.Context, 58 | )(path: c.Expr[T => U], op: c.Tree): c.Tree = { 59 | import c.universe._ 60 | 61 | // When deriving, Magnolia derives for all subtypes in the hierarchy 62 | // (including subclasses of sub-sealed traits) therefore when checking 63 | // We need to resolve all subclasses in the hierarchy 64 | @tailrec 65 | def resolveAllSubtypesInHierarchy(toCheck: Vector[Symbol], accum: Vector[Symbol]): Vector[Symbol] = { 66 | if (toCheck.isEmpty) accum 67 | else { 68 | val newLeafSubTypes = mutable.ArrayBuffer.from(toCheck) 69 | val nextToCheck = mutable.ArrayBuffer.empty[Symbol] 70 | toCheck.foreach { s => 71 | val subTypes = s.asClass.knownDirectSubclasses 72 | if (subTypes.isEmpty) { 73 | newLeafSubTypes += s 74 | } else { 75 | nextToCheck ++= subTypes 76 | } 77 | } 78 | 79 | resolveAllSubtypesInHierarchy(nextToCheck.toVector, accum ++ newLeafSubTypes) 80 | } 81 | } 82 | 83 | @tailrec 84 | def collectPathElements(tree: c.Tree, acc: List[String]): List[String] = { 85 | tree match { 86 | case q"$parent.$child" => { 87 | collectPathElements(parent, child.decodedName.toString :: acc) 88 | } 89 | case _: Ident => acc 90 | case q"$func[..$tArgs]($t)($ev)" => { 91 | collectPathElements(t, acc) 92 | } 93 | case q"$func[$superType]($rest).subType[$subType]" => { 94 | val superTypeSym = superType.symbol.asClass 95 | val subTypeSym = subType.symbol.asClass 96 | 97 | val allKnownSubTypesInHierarchy = 98 | resolveAllSubtypesInHierarchy(toCheck = superTypeSym.knownDirectSubclasses.toVector, accum = Vector.empty) 99 | if (allKnownSubTypesInHierarchy.contains(subTypeSym)) { 100 | collectPathElements(rest, subTypeSym.name.decodedName.toString :: acc) 101 | } else { 102 | c.abort( 103 | c.enclosingPosition, 104 | s"""Specified subtype is not a known direct subtype of $superTypeSym. 105 | |The supertype needs to be sealed, and you might need to ensure that both the supertype 106 | |and subtype gets compiled before this invocation. 107 | |See also: .""".stripMargin, 108 | ) 109 | } 110 | } 111 | case _ => 112 | c.abort(c.enclosingPosition, s"$requiredShapeMsg, got: ${tree}") 113 | } 114 | } 115 | 116 | path.tree match { 117 | case q"($_) => $pathBody" => { 118 | val pathStr = collectPathElements(pathBody, List.empty) 119 | q"""${c.prefix.tree}.configureRaw( 120 | _root_.difflicious.ConfigurePath.fromPath($pathStr), 121 | $op 122 | ) match { 123 | case Right(newDiffer) => newDiffer 124 | case Left(e) => throw e 125 | } 126 | """ 127 | } 128 | case _ => c.abort(c.enclosingPosition, s"$requiredShapeMsg, got: ${path.tree}") 129 | } 130 | 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /modules/core/src/main/scala-3/difflicious/DifferGen.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | import difflicious.DiffResult.MismatchTypeResult 3 | import difflicious.differ.RecordDiffer 4 | import difflicious.utils.TypeName 5 | import difflicious.utils.TypeName.SomeTypeName 6 | import difflicious.internal.EitherGetSyntax._ 7 | 8 | import scala.collection.immutable.ListMap 9 | import magnolia1._ 10 | 11 | import scala.collection.mutable 12 | 13 | trait DifferGen extends Derivation[Differ]: 14 | override def join[T](ctx: CaseClass[Differ, T]): Differ[T] = 15 | new RecordDiffer[T]( 16 | ctx.params.map { p => 17 | val getter = p.deref 18 | p.label -> Tuple2(getter.asInstanceOf[(T => Any)], p.typeclass.asInstanceOf[Differ[Any]]) 19 | }.to(ListMap), 20 | isIgnored = false, 21 | typeName = toDiffliciousTypeName(ctx.typeInfo) 22 | ) 23 | 24 | override def split[T](ctx: SealedTrait[Differ, T]): Differ[T] = 25 | new SealedTraitDiffer(ctx, isIgnored = false) 26 | 27 | final class SealedTraitDiffer[T](ctx: SealedTrait[Differ, T], isIgnored: Boolean) extends Differ[T]: 28 | override type R = DiffResult 29 | 30 | override def diff(inputs: DiffInput[T]): DiffResult = inputs match 31 | case DiffInput.ObtainedOnly(obtained) => 32 | ctx.choose(obtained)(sub => sub.typeclass.diff(DiffInput.ObtainedOnly(sub.cast(obtained)))) 33 | case DiffInput.ExpectedOnly(expected) => 34 | ctx.choose(expected)(sub => sub.typeclass.diff(DiffInput.ExpectedOnly(sub.cast(expected)))) 35 | case DiffInput.Both(obtained, expected) => 36 | ctx.choose(obtained) { obtainedSubtype => 37 | ctx.choose(expected) { expectedSubtype => 38 | if obtainedSubtype.typeInfo.short == expectedSubtype.typeInfo.short then 39 | obtainedSubtype.typeclass.asInstanceOf[Differ[T]].diff(obtainedSubtype.value, expectedSubtype.value) 40 | else MismatchTypeResult( 41 | obtained = obtainedSubtype.typeclass.diff(DiffInput.ObtainedOnly(obtainedSubtype.cast(obtained))), 42 | obtainedTypeName = toDiffliciousTypeName(obtainedSubtype.typeInfo), 43 | expected = expectedSubtype.typeclass.diff(DiffInput.ExpectedOnly(expectedSubtype.cast(expected))), 44 | expectedTypeName = toDiffliciousTypeName(expectedSubtype.typeInfo), 45 | pairType = PairType.Both, 46 | isIgnored = isIgnored, 47 | ) 48 | } 49 | } 50 | 51 | 52 | override def configureIgnored(newIgnored: Boolean): Differ[T] = 53 | val newSubtypes = mutable.ArrayBuffer.empty[SealedTrait.Subtype[Differ, T, Any]] 54 | ctx.subtypes.foreach { sub => 55 | newSubtypes += SealedTrait.Subtype[Differ, T, Any]( 56 | typeInfo = sub.typeInfo, 57 | annotations = sub.annotations, 58 | typeAnnotations = sub.typeAnnotations, 59 | isObject = sub.isObject, 60 | index = sub.index, 61 | callByNeed = 62 | CallByNeed(sub.typeclass.configureRaw(ConfigurePath.current, ConfigureOp.SetIgnored(newIgnored)).unsafeGet.asInstanceOf[Differ[Any]]), 63 | isType = sub.cast.isDefinedAt, 64 | asType = sub.cast.apply, 65 | ) 66 | } 67 | val newSealedTrait = new SealedTrait( 68 | typeInfo = ctx.typeInfo, 69 | subtypes = IArray(newSubtypes.toArray: _*), 70 | annotations = ctx.annotations, 71 | typeAnnotations = ctx.typeAnnotations, 72 | isEnum = ctx.isEnum, 73 | ) 74 | new SealedTraitDiffer[T](newSealedTrait, isIgnored = newIgnored) 75 | 76 | protected def configurePath( 77 | step: String, 78 | nextPath: ConfigurePath, 79 | op: ConfigureOp 80 | ): Either[ConfigureError, Differ[T]] = 81 | ctx.subtypes.zipWithIndex.find{ (sub, _) => sub.typeInfo.short == step} match { 82 | case Some((sub, idx)) => 83 | sub.typeclass 84 | .configureRaw(nextPath, op) 85 | .map { newDiffer => 86 | val newSubtype = SealedTrait.Subtype[Differ, T, Any]( 87 | typeInfo = sub.typeInfo, 88 | annotations = sub.annotations, 89 | typeAnnotations = sub.typeAnnotations, 90 | isObject = sub.isObject, 91 | index = sub.index, 92 | callByNeed = CallByNeed(newDiffer.asInstanceOf[Differ[Any]]), 93 | isType = sub.cast.isDefinedAt, 94 | asType = sub.cast.apply, 95 | ) 96 | val newSubtypes = ctx.subtypes.updated(idx, newSubtype) 97 | val newSealedTrait = new SealedTrait( 98 | typeInfo = ctx.typeInfo, 99 | subtypes = newSubtypes, 100 | annotations = ctx.annotations, 101 | typeAnnotations = ctx.typeAnnotations, 102 | isEnum = ctx.isEnum, 103 | ) 104 | new SealedTraitDiffer[T](newSealedTrait, isIgnored) 105 | } 106 | case None => 107 | Left(ConfigureError.UnrecognizedSubType(nextPath, ctx.subtypes.map(_.typeInfo.short).toVector)) 108 | } 109 | 110 | protected def configurePairBy(path: ConfigurePath, op: ConfigureOp.PairBy[_]): Either[ConfigureError, Differ[T]] = 111 | Left(ConfigureError.InvalidConfigureOp(path, op, "SealedTraitDiffer")) 112 | 113 | end SealedTraitDiffer 114 | 115 | private def toDiffliciousTypeName(typeInfo: TypeInfo): SomeTypeName = { 116 | TypeName( 117 | long = s"${typeInfo.owner}.${typeInfo.short}", 118 | short = typeInfo.short, 119 | typeArguments = typeInfo.typeParams.map(toDiffliciousTypeName).toList 120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/DiffResultPrinter.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.DiffResult.MapResult.Entry 4 | import difflicious.DiffResult.ValueResult 5 | import fansi.{Color, EscapeAttr, Str} 6 | 7 | object DiffResultPrinter { 8 | private val colorObtained = Color.Red 9 | private val colorExpected = Color.Green 10 | private val colorIgnored = Color.DarkGray 11 | 12 | private val indentStep = 2 13 | 14 | private val ignoredStr: Str = Str("[IGNORED]").overlay(colorIgnored) 15 | 16 | private val emptyStr = Str("") 17 | 18 | def printDiffResult( 19 | res: DiffResult, 20 | ): Unit = { 21 | println(consoleOutput(res, 0).render) 22 | } 23 | 24 | def consoleOutput( 25 | res: DiffResult, 26 | indentLevel: Int, 27 | ): fansi.Str = { 28 | if (res.isIgnored) ignoredStr 29 | else 30 | res match { 31 | case r: DiffResult.ListResult => { 32 | val indentForFields = Str("\n" ++ indentLevel.asSpacesPlus1) 33 | val listStrs = r.items 34 | .map { res => 35 | consoleOutput(res, indentLevel + 1) 36 | } 37 | .reduceLeftOption[Str] { case (accum, next) => accum ++ "," ++ indentForFields ++ next } 38 | .map(accum => indentForFields ++ accum) 39 | .getOrElse(emptyStr) 40 | val allStr = Str(s"${r.typeName.short}(") ++ listStrs ++ Str(s"\n${indentLevel.asSpaces})") 41 | colorOnMatchType(str = allStr, matchType = r.pairType) 42 | } 43 | case r: DiffResult.RecordResult => { 44 | val indentForFields = Str("\n" ++ indentLevel.asSpacesPlus1) 45 | val fieldsStr = r.fields 46 | .map { case (fieldName, vRes) => 47 | Str(fieldName) ++ ": " ++ consoleOutput( 48 | vRes, 49 | indentLevel = indentLevel + 1, 50 | ) 51 | } 52 | .reduceLeftOption[Str] { case (accum, nextStr) => accum ++ "," ++ indentForFields ++ nextStr } 53 | .map(accum => indentForFields ++ accum) 54 | .getOrElse(emptyStr) 55 | val allStr = Str(s"${r.typeName.short}(") ++ fieldsStr ++ s"\n${indentLevel.asSpaces})" 56 | colorOnMatchType(str = allStr, matchType = r.pairType) 57 | } 58 | case r: DiffResult.MapResult => { 59 | val indentPlusStr = Str(s"\n${indentLevel.asSpacesPlus1}") 60 | val keyValStr = r.entries 61 | .map { case Entry(keyJson, valueDiff) => 62 | val keyStr = 63 | colorOnMatchType(str = Str(keyJson), matchType = valueDiff.pairType) 64 | val valueStr = consoleOutput(valueDiff, indentLevel + 2) 65 | keyStr ++ " -> " ++ valueStr 66 | } 67 | .reduceLeftOption[Str] { case (accum, nextStr) => 68 | accum ++ "," ++ indentPlusStr ++ nextStr 69 | } 70 | .map(accum => indentPlusStr ++ accum) 71 | .getOrElse(emptyStr) 72 | val allStr = Str(r.typeName.short ++ "(") ++ keyValStr ++ s"\n${indentLevel.asSpaces})" 73 | colorOnMatchType(allStr, r.pairType) 74 | } 75 | case r: DiffResult.MismatchTypeResult => { 76 | val titleStr = Str(r.obtainedTypeName.short).overlay(colorObtained) ++ " != " ++ Str(r.expectedTypeName.short) 77 | .overlay(colorExpected) 78 | val allStr = { 79 | val obtainedStr = consoleOutput(r.obtained, indentLevel) 80 | val expectedStr = consoleOutput(r.expected, indentLevel) 81 | val indentSplitStr = Str(s"\n${indentLevel.asSpaces}") 82 | titleStr ++ 83 | indentSplitStr ++ (Str("=== Obtained ===") ++ indentSplitStr ++ obtainedStr).overlay(colorObtained) ++ 84 | indentSplitStr ++ (Str("=== Expected ===") ++ indentSplitStr ++ expectedStr).overlay(colorExpected) 85 | } 86 | colorOnMatchType(str = allStr, matchType = r.pairType) 87 | } 88 | case result: DiffResult.ValueResult => 89 | result match { 90 | case r: ValueResult.Both => { 91 | val obtainedStr = Str(r.obtained) 92 | if (r.isSame) { 93 | obtainedStr 94 | } else { 95 | val expectedStr = Str(r.expected) 96 | obtainedStr.overlay(colorObtained) ++ " -> " ++ expectedStr.overlay(colorExpected) 97 | } 98 | } 99 | case ValueResult.ObtainedOnly(obtained, _) => fansi.Str(obtained).overlay(colorObtained) 100 | case ValueResult.ExpectedOnly(expected, _) => fansi.Str(expected).overlay(colorExpected) 101 | } 102 | } 103 | } 104 | 105 | private val dummyColoSeparator = Str(" ").overlay(Color.Reset) 106 | private def colorOnMatchType( 107 | str: Str, 108 | matchType: PairType, 109 | ): Str = { 110 | // Because SBT (and maybe other tools) put their own logging prefix on each line (e.g. [info]) 111 | // they effectively resets the color of each line. Therefore we need to ensure every line is "recolored" 112 | // Because we always indent with spaces, we do this by recoloring the first space we find on each line. 113 | def recolorEachLine(orig: Str, color: EscapeAttr) = { 114 | orig.plainText.linesIterator 115 | .map { s => 116 | if (s.startsWith(" ")) 117 | dummyColoSeparator ++ Str(s.drop(1)).overlay(color) 118 | else 119 | Str(s).overlay(color) 120 | } 121 | .reduceLeft((accum, next) => accum ++ Str("\n") ++ next) 122 | } 123 | 124 | matchType match { 125 | case PairType.Both => str 126 | case PairType.ObtainedOnly => { 127 | recolorEachLine(str, colorObtained) 128 | } 129 | case PairType.ExpectedOnly => 130 | recolorEachLine(str, colorExpected) 131 | } 132 | } 133 | 134 | implicit private class IntExt(val i: Int) extends AnyVal { 135 | @inline 136 | def asSpaces: String = " " * (i * indentStep) 137 | 138 | @inline 139 | def asSpacesPlus1: String = " " * ((i + 1) * indentStep) 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /modules/core/src/main/scala-2.13/difflicious/DifferGen.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | import difflicious.DiffResult.MismatchTypeResult 3 | import difflicious.differ.RecordDiffer 4 | import difflicious.internal.EitherGetSyntax._ 5 | import difflicious.utils.TypeName.SomeTypeName 6 | import magnolia1._ 7 | 8 | import scala.collection.mutable 9 | import scala.collection.immutable.ListMap 10 | 11 | trait DifferGen { 12 | type Typeclass[T] = Differ[T] 13 | 14 | def join[T](ctx: ReadOnlyCaseClass[Differ, T]): Differ[T] = { 15 | new RecordDiffer[T]( 16 | ctx.parameters 17 | .map { p => 18 | val getter = p.dereference _ 19 | p.label -> Tuple2(getter.asInstanceOf[(T => Any)], p.typeclass.asInstanceOf[Differ[Any]]) 20 | } 21 | .to(ListMap), 22 | isIgnored = false, 23 | typeName = toDiffliciousTypeName(ctx.typeName), 24 | ) 25 | } 26 | 27 | final class SealedTraitDiffer[T](ctx: SealedTrait[Differ, T], isIgnored: Boolean) extends Differ[T] { 28 | // $COVERAGE-OFF$ 29 | require( 30 | { 31 | val allShortNames = ctx.subtypes.map(_.typeName.short) 32 | allShortNames.toSet.size == allShortNames.size 33 | }, 34 | "Currently all subclass names across the whole sealed trait hierarchy must be distinct for simplicity of the configure API. " + 35 | "Please raise an issue if you need subclasses with the same names", 36 | ) 37 | // $COVERAGE-ON$ 38 | 39 | override type R = DiffResult 40 | 41 | override def diff(inputs: DiffInput[T]): DiffResult = inputs match { 42 | case DiffInput.ObtainedOnly(obtained) => 43 | ctx.split(obtained)(sub => sub.typeclass.diff(DiffInput.ObtainedOnly(sub.cast(obtained)))) 44 | case DiffInput.ExpectedOnly(expected) => 45 | ctx.split(expected)(sub => sub.typeclass.diff(DiffInput.ExpectedOnly(sub.cast(expected)))) 46 | case DiffInput.Both(obtained, expected) => { 47 | ctx.split(obtained) { obtainedSubtype => 48 | ctx.split(expected) { expectedSubtype => 49 | if (obtainedSubtype.typeName.short == expectedSubtype.typeName.short) { 50 | obtainedSubtype.typeclass 51 | .diff( 52 | obtainedSubtype.cast(obtained), 53 | expectedSubtype.cast(expected).asInstanceOf[obtainedSubtype.SType], 54 | ) 55 | } else { 56 | MismatchTypeResult( 57 | obtained = obtainedSubtype.typeclass.diff(DiffInput.ObtainedOnly(obtainedSubtype.cast(obtained))), 58 | obtainedTypeName = toDiffliciousTypeName(obtainedSubtype.typeName), 59 | expected = expectedSubtype.typeclass.diff(DiffInput.ExpectedOnly(expectedSubtype.cast(expected))), 60 | expectedTypeName = toDiffliciousTypeName(expectedSubtype.typeName), 61 | pairType = PairType.Both, 62 | isIgnored = isIgnored, 63 | ) 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | override def configureIgnored(newIgnored: Boolean): Typeclass[T] = { 71 | val newSubTypes = mutable.ArrayBuffer.empty[Subtype[Differ, T]] 72 | ctx.subtypes.foreach { sub => 73 | newSubTypes += Subtype( 74 | name = sub.typeName, 75 | idx = sub.index, 76 | anns = sub.annotationsArray, 77 | tpeAnns = sub.typeAnnotationsArray, 78 | tc = 79 | CallByNeed(sub.typeclass.configureRaw(ConfigurePath.current, ConfigureOp.SetIgnored(newIgnored)).unsafeGet), 80 | isType = sub.cast.isDefinedAt, 81 | asType = sub.cast.apply, 82 | ) 83 | } 84 | val newSealedTrait = new SealedTrait( 85 | typeName = ctx.typeName, 86 | subtypesArray = newSubTypes.toArray, 87 | annotationsArray = ctx.annotations.toArray, 88 | typeAnnotationsArray = ctx.typeAnnotations.toArray, 89 | ) 90 | new SealedTraitDiffer[T](newSealedTrait, isIgnored = newIgnored) 91 | } 92 | 93 | override def configurePath( 94 | step: String, 95 | nextPath: ConfigurePath, 96 | op: ConfigureOp, 97 | ): Either[ConfigureError, Typeclass[T]] = 98 | ctx.subtypes.zipWithIndex.find { case (sub, _) => sub.typeName.short == step } match { 99 | case Some((sub, idx)) => 100 | sub.typeclass 101 | .configureRaw(nextPath, op) 102 | .map { newDiffer => 103 | Subtype( 104 | name = sub.typeName, 105 | idx = sub.index, 106 | anns = sub.annotationsArray, 107 | tpeAnns = sub.typeAnnotationsArray, 108 | tc = CallByNeed(newDiffer), 109 | isType = sub.cast.isDefinedAt, 110 | asType = sub.cast.apply, 111 | ) 112 | } 113 | .map { newSubType => 114 | val newSubTypes = ctx.subtypes.updated(idx, newSubType) 115 | val newSealedTrait = new SealedTrait( 116 | typeName = ctx.typeName, 117 | subtypesArray = newSubTypes.toArray, 118 | annotationsArray = ctx.annotations.toArray, 119 | typeAnnotationsArray = ctx.typeAnnotations.toArray, 120 | ) 121 | new SealedTraitDiffer[T](newSealedTrait, isIgnored) 122 | } 123 | case None => 124 | Left(ConfigureError.UnrecognizedSubType(nextPath, ctx.subtypes.map(_.typeName.short).toVector)) 125 | } 126 | 127 | override def configurePairBy(path: ConfigurePath, op: ConfigureOp.PairBy[_]): Either[ConfigureError, Typeclass[T]] = 128 | Left(ConfigureError.InvalidConfigureOp(path, op, "SealedTraitDiffer")) 129 | 130 | } 131 | 132 | def split[T](ctx: SealedTrait[Differ, T]): Differ[T] = 133 | new SealedTraitDiffer[T](ctx, isIgnored = false) 134 | 135 | def derived[T]: Differ[T] = macro Magnolia.gen[T] 136 | 137 | private def toDiffliciousTypeName(typeName: magnolia1.TypeName): SomeTypeName = { 138 | difflicious.utils.TypeName( 139 | long = typeName.full, 140 | short = typeName.short, 141 | typeArguments = typeName.typeArguments 142 | .map( 143 | // $COVERAGE-OFF$ Type params aren't printed when type mismatches 144 | toDiffliciousTypeName, 145 | // $COVERAGE-ON$ 146 | ) 147 | .toList, 148 | ) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/difflicious/differ/SeqDiffer.scala: -------------------------------------------------------------------------------- 1 | package difflicious.differ 2 | 3 | import difflicious.DiffResult.ListResult 4 | import difflicious.utils.SeqLike 5 | import difflicious.ConfigureOp.PairBy 6 | import difflicious.{Differ, DiffResult, ConfigureOp, ConfigureError, ConfigurePath, DiffInput, PairType} 7 | import SeqDiffer.diffPairByFunc 8 | import difflicious.utils.TypeName.SomeTypeName 9 | 10 | import scala.collection.mutable 11 | 12 | final class SeqDiffer[F[_], A]( 13 | isIgnored: Boolean, 14 | pairBy: PairBy[A], 15 | itemDiffer: Differ[A], 16 | typeName: SomeTypeName, 17 | asSeq: SeqLike[F], 18 | ) extends Differ[F[A]] { 19 | override type R = ListResult 20 | 21 | override def diff(inputs: DiffInput[F[A]]): R = inputs.map(asSeq.asSeq) match { 22 | case DiffInput.Both(actual, expected) => { 23 | pairBy match { 24 | case PairBy.Index => { 25 | val diffResults = actual 26 | .map(Some(_)) 27 | .zipAll(expected.map(Some(_)), None, None) 28 | .map { 29 | case (Some(ob), Some(exp)) => itemDiffer.diff(DiffInput.Both(ob, exp)) 30 | case (Some(ob), None) => itemDiffer.diff(DiffInput.ObtainedOnly(ob)) 31 | case (None, Some(exp)) => itemDiffer.diff(DiffInput.ExpectedOnly(exp)) 32 | case (None, None) => 33 | // $COVERAGE-OFF$ 34 | throw new RuntimeException( 35 | "Unexpected: Both obtained and expected side is None in SeqDiffer. " + 36 | "This shouldn't happen and is most likely a difflicious bug", 37 | ) 38 | // $COVERAGE-ON$ 39 | } 40 | .toVector 41 | 42 | ListResult( 43 | typeName = typeName, 44 | items = diffResults, 45 | pairType = PairType.Both, 46 | isIgnored = isIgnored, 47 | isOk = isIgnored || diffResults.forall(_.isOk), 48 | ) 49 | } 50 | case PairBy.ByFunc(func) => { 51 | val (results, allIsOk) = diffPairByFunc(actual, expected, func, itemDiffer) 52 | ListResult( 53 | typeName = typeName, 54 | items = results, 55 | pairType = PairType.Both, 56 | isIgnored = isIgnored, 57 | isOk = isIgnored || allIsOk, 58 | ) 59 | } 60 | } 61 | } 62 | case DiffInput.ObtainedOnly(actual) => 63 | ListResult( 64 | typeName = typeName, 65 | items = actual.map { a => 66 | itemDiffer.diff(DiffInput.ObtainedOnly(a)) 67 | }.toVector, 68 | pairType = PairType.ObtainedOnly, 69 | isIgnored = isIgnored, 70 | isOk = isIgnored, 71 | ) 72 | case DiffInput.ExpectedOnly(expected) => 73 | ListResult( 74 | typeName = typeName, 75 | items = expected.map { a => 76 | itemDiffer.diff(DiffInput.ExpectedOnly(a)) 77 | }.toVector, 78 | pairType = PairType.ExpectedOnly, 79 | isIgnored = isIgnored, 80 | isOk = isIgnored, 81 | ) 82 | } 83 | 84 | override def configureIgnored(newIgnored: Boolean): Differ[F[A]] = 85 | new SeqDiffer[F, A]( 86 | isIgnored = newIgnored, 87 | pairBy = pairBy, 88 | itemDiffer = itemDiffer, 89 | typeName = typeName, 90 | asSeq = asSeq, 91 | ) 92 | 93 | override def configurePath( 94 | step: String, 95 | nextPath: ConfigurePath, 96 | op: ConfigureOp, 97 | ): Either[ConfigureError, Differ[F[A]]] = 98 | if (step == "each") { 99 | itemDiffer.configureRaw(nextPath, op).map { newItemDiffer => 100 | new SeqDiffer[F, A]( 101 | isIgnored = isIgnored, 102 | pairBy = pairBy, 103 | itemDiffer = newItemDiffer, 104 | typeName = typeName, 105 | asSeq = asSeq, 106 | ) 107 | } 108 | } else Left(ConfigureError.NonExistentField(nextPath, "SeqDiffer")) 109 | 110 | override def configurePairBy(path: ConfigurePath, op: PairBy[_]): Either[ConfigureError, Differ[F[A]]] = 111 | op match { 112 | case PairBy.Index => 113 | Right( 114 | new SeqDiffer[F, A]( 115 | isIgnored = isIgnored, 116 | pairBy = PairBy.Index, 117 | itemDiffer = itemDiffer, 118 | typeName = typeName, 119 | asSeq = asSeq, 120 | ), 121 | ) 122 | case m: PairBy.ByFunc[_, _] => 123 | Right( 124 | new SeqDiffer[F, A]( 125 | isIgnored = isIgnored, 126 | pairBy = m.asInstanceOf[ConfigureOp.PairBy[A]], 127 | itemDiffer = itemDiffer, 128 | typeName = typeName, 129 | asSeq = asSeq, 130 | ), 131 | ) 132 | } 133 | } 134 | 135 | object SeqDiffer { 136 | def create[F[_], A]( 137 | itemDiffer: Differ[A], 138 | typeName: SomeTypeName, 139 | asSeq: SeqLike[F], 140 | ): SeqDiffer[F, A] = new SeqDiffer[F, A]( 141 | isIgnored = false, 142 | pairBy = PairBy.Index, 143 | itemDiffer = itemDiffer, 144 | typeName = typeName, 145 | asSeq = asSeq, 146 | ) 147 | 148 | // Given two lists of item, find "matching" items using te provided function 149 | // (where "matching" means ==). For example we might want to items by 150 | // person name. 151 | private[difflicious] def diffPairByFunc[A]( 152 | obtained: Seq[A], 153 | expected: Seq[A], 154 | func: A => Any, 155 | itemDiffer: Differ[A], 156 | ): (Vector[DiffResult], Boolean) = { 157 | val matchedIndexes = mutable.BitSet.empty 158 | val results = mutable.ArrayBuffer.empty[DiffResult] 159 | val expWithIdx = expected.zipWithIndex 160 | var allIsOk = true 161 | obtained.foreach { a => 162 | val aMatchVal = func(a) 163 | val found = expWithIdx.find { 164 | case (e, idx) => 165 | if (!matchedIndexes.contains(idx) && aMatchVal == func(e)) { 166 | val res = itemDiffer.diff(a, e) 167 | results += res 168 | matchedIndexes += idx 169 | allIsOk &= res.isOk 170 | true 171 | } else { 172 | false 173 | } 174 | } 175 | 176 | if (found.isEmpty) { 177 | results += itemDiffer.diff(DiffInput.ObtainedOnly(a)) 178 | allIsOk = false 179 | } 180 | } 181 | 182 | expWithIdx.foreach { 183 | case (e, idx) => 184 | if (!matchedIndexes.contains(idx)) { 185 | results += itemDiffer.diff(DiffInput.ExpectedOnly(e)) 186 | allIsOk = false 187 | } 188 | } 189 | 190 | (results.toVector, allIsOk) 191 | } 192 | 193 | } 194 | -------------------------------------------------------------------------------- /docs/docs/docs/TypesOfDiffer.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: docs 3 | title: "Types of Differ" 4 | permalink: docs/types-of-differs 5 | --- 6 | 7 | # Types of Differs 8 | 9 | Here we list the kinds of Differs and how you can use them. 10 | 11 | The examples below assume the following imports: 12 | 13 | ```scala mdoc:silent 14 | import difflicious._ 15 | import difflicious.implicits._ 16 | ``` 17 | 18 | # Value Differs 19 | 20 | For basic types like `Int`, `Double` and `String` we typically can compare them directly e.g. using `equals` method. 21 | 22 | If you have a simple type where you don't need any advanced diffing, then you can use `Differ.useEquals` to make a 23 | Differ instance for it. 24 | 25 | ```scala mdoc:silent 26 | case class MyInt(i: Int) 27 | 28 | object MyInt { 29 | implicit val differ: Differ[MyInt] = Differ.useEquals[MyInt](valueToString = _.toString) 30 | } 31 | ``` 32 | 33 | ```scala mdoc:silent 34 | MyInt.differ.diff(MyInt(1), MyInt(2)) 35 | ``` 36 | 37 |
 38 | MyInt(1) -> MyInt(2)
 39 | 
40 | 41 | # Differs for Algebraic Data Types (enums, sealed traits and case classes) 42 | 43 | You can derive `Differ` for a case class provided that there is a `Differ` instance for all your fields. 44 | 45 | Similarly, you can derive a `Differ` for a sealed trait (Also called **Enums** in Scala 3) provided that we're able to 46 | derive a Differ for subclass of the sealed trait (or a Differ instance is already in scope for that subclass) 47 | 48 | 49 | ### Case class 50 | 51 | ```scala mdoc:silent 52 | final case class Person(name: String, age: Int) 53 | 54 | object Person { 55 | implicit val differ: Differ[Person] = Differ.derived[Person] 56 | } 57 | ``` 58 | 59 | ```scala mdoc:silent 60 | Person.differ.diff( 61 | Person("Alice", 40), 62 | Person("Alice", 35) 63 | ) 64 | ``` 65 | 66 |
 67 | Person(
 68 |   name: "Alice",
 69 |   age: 40 -> 35,
 70 | )
 71 | 
72 | 73 | ### Sealed trait / Scala 3 Enum 74 | 75 | ```scala mdoc:silent 76 | // Deriving Differ instance for sealed trait 77 | sealed trait HousePet 78 | final case class Dog(name: String, age: Int) extends HousePet 79 | final case class Cat(name: String, livesLeft: Int) extends HousePet 80 | 81 | object HousePet { 82 | implicit val differ: Differ[HousePet] = Differ.derived[HousePet] 83 | } 84 | ``` 85 | 86 | ```scala mdoc:silent 87 | HousePet.differ.diff( 88 | Dog("Lucky", 1), 89 | Cat("Lucky", 1) 90 | ) 91 | ``` 92 | 93 |
 94 | Dog != Cat
 95 | === Obtained ===
 96 | Dog(
 97 |   name: "Lucky",
 98 |   age: 1,
 99 | )
100 | === Expected ===
101 | Cat(
102 |   name: "Lucky",
103 |   livesLeft: 1,
104 | )
105 | 
106 | 107 | # Seq Differ 108 | 109 | Differ for sequences allow diffing immutable sequences like `Seq`, `List`, and `Vector`. 110 | 111 | By default, Seq Differs will match elements by their index in the sequence. 112 | 113 | In the example below 114 | 115 | - **Bob**'s age 116 | - **Alice** isn't expected to be in list 117 | - **Charles** is expected but missing 118 | 119 | ```scala mdoc:silent 120 | val alice = Person("Alice", 30) 121 | val bob = Person("Bob", 25) 122 | val bob50 = Person("Bob", 50) 123 | val charles = Person("Charles", 80) 124 | 125 | Differ.seqDiffer[List, Person].diff( 126 | List(alice, bob50), 127 | List(alice, bob, charles) 128 | ) 129 | ``` 130 | 131 |
132 | List(
133 |   Person(
134 |     name: "Alice",
135 |     age: 30,
136 |   ),
137 |   Person(
138 |     name: "Bob",
139 |     age: 50 -> 25,
140 |   ),
141 |   Person(
142 |     name: "Charles",
143 |     age: 80,
144 |   ),
145 | )
146 | 
147 | 148 | ## Pair by field 149 | 150 | In many test scenarios we actually don't care about order of elements, as long as the two sequences 151 | contains the same elements. One example of this is inserting multiple records into a database and then retrieving them 152 | , where you expect the same records to be returned by not necessarily in the original order. 153 | 154 | In this case, you can configure a `Differ` to pair by a field instead. 155 | 156 | ```scala mdoc:silent 157 | val differByName = Differ[List[Person]].pairBy(_.name) 158 | 159 | differByName.diff( 160 | List(bob50, charles, alice), 161 | List(alice, bob, charles) 162 | ) 163 | ``` 164 | 165 | When we match by a person's name instead of index, we can now easily spot that Bob has the wrong age. 166 | 167 |
168 | List(
169 |   Person(
170 |     name: "Bob",
171 |     age: 50 -> 25,
172 |   ),
173 |   Person(
174 |     name: "Charles",
175 |     age: 80,
176 |   ),
177 |   Person(
178 |     name: "Alice",
179 |     age: 30,
180 |   ),
181 | )
182 | 
183 | 184 | # Map differ 185 | 186 | Map differ pair entries with the same keys and compare the values. Missing key-values will also be reported in the result. 187 | 188 | It requires 189 | 190 | - a `ValueDiffer` instance for the map key type (for display purposes) 191 | - a `Differ` instance for the map value type 192 | 193 | ```scala mdoc:silent 194 | Differ[Map[String, Person]].diff( 195 | Map( 196 | "a" -> alice, 197 | "b" -> bob 198 | ), 199 | Map( 200 | "b" -> bob50, 201 | "c" -> charles 202 | ), 203 | ) 204 | ``` 205 | 206 |
207 | Map(
208 |   "a" -> Person(
209 |       name: "Alice",
210 |       age: 30,
211 |     ),
212 |   "b" -> Person(
213 |       name: "Bob",
214 |       age: 25 -> 50,
215 |     ),
216 |   "c" -> Person(
217 |       name: "Charles",
218 |       age: 80,
219 |     ),
220 | )
221 | 
222 | 223 | # Set differ 224 | 225 | Set differ can diff two Sets by pairing the set elements and diffing them. 226 | By default, the pairing is based on matching elements that are equal to each other (using `equals`). 227 | 228 | However, you most likely want to pair elements using a field on an element instead for better diffs reports 229 | (See next section). 230 | 231 | ## Pair by field 232 | 233 | For the best error reporting, you want to configure `SetDiffer` to pair by a field. 234 | 235 | ```scala mdoc:nest:silent 236 | val differByName: Differ[Set[Person]] = Differ[Set[Person]].pairBy(_.name) 237 | 238 | differByName.diff( 239 | Set(bob50, charles, alice), 240 | Set(alice, bob, charles) 241 | ) 242 | ``` 243 | 244 |
245 | Set(
246 |   Person(
247 |     name: "Bob",
248 |     age: 50 -> 25,
249 |   ),
250 |   Person(
251 |     name: "Charles",
252 |     age: 80,
253 |   ),
254 |   Person(
255 |     name: "Alice",
256 |     age: 30,
257 |   ),
258 | )
259 | 
260 | 261 | # Always ignored Differ 262 | 263 | Sometimes for certain types you can't really compare them (e.g. Something that's not a plain data structure). 264 | 265 | In that case you can use `Differ.alwaysIgnore` 266 | 267 | ```scala mdoc:silent 268 | class CantCompare() 269 | 270 | val alwaysIgnoredDiffer: Differ[CantCompare] = Differ.alwaysIgnore[CantCompare] 271 | ``` 272 | 273 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala/difflicious/DifferTimeInstancesSpec.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.testutils._ 4 | import munit.ScalaCheckSuite 5 | 6 | import java.time._ 7 | import org.scalacheck.{Gen, Arbitrary} 8 | 9 | class DifferTimeInstancesSpec extends ScalaCheckSuite { 10 | 11 | test("DayOfWeek") { 12 | implicit val arb: Arbitrary[DayOfWeek] = Arbitrary(Gen.chooseNum(1, 7).map(DayOfWeek.of)) 13 | assertOkIfValuesEqualProp(implicitly[Differ[DayOfWeek]]) && 14 | assertNotOkIfNotEqualProp(implicitly[Differ[DayOfWeek]]) && 15 | assertIsOkIfIgnoredProp(implicitly[Differ[DayOfWeek]]) 16 | } 17 | 18 | test("Duration") { 19 | implicit val arb: Arbitrary[Duration] = Arbitrary(Gen.posNum[Long].map(Duration.ofNanos)) 20 | assertOkIfValuesEqualProp(implicitly[Differ[Duration]]) && 21 | assertNotOkIfNotEqualProp(implicitly[Differ[Duration]]) && 22 | assertIsOkIfIgnoredProp(implicitly[Differ[Duration]]) 23 | } 24 | 25 | test("Instant") { 26 | implicit val arb: Arbitrary[Instant] = Arbitrary(Gen.posNum[Long].map(Instant.ofEpochMilli)) 27 | assertOkIfValuesEqualProp(implicitly[Differ[Instant]]) && 28 | assertNotOkIfNotEqualProp(implicitly[Differ[Instant]]) && 29 | assertIsOkIfIgnoredProp(implicitly[Differ[Instant]]) 30 | } 31 | 32 | test("LocalDate") { 33 | implicit val arb: Arbitrary[LocalDate] = Arbitrary(for { 34 | year <- Gen.posNum[Int] 35 | month <- Gen.choose[Int](1, 12) 36 | days <- Gen.choose[Int](1, 28) 37 | } yield LocalDate.of(year, month, days)) 38 | assertOkIfValuesEqualProp(implicitly[Differ[LocalDate]]) && 39 | assertNotOkIfNotEqualProp(implicitly[Differ[LocalDate]]) && 40 | assertIsOkIfIgnoredProp(implicitly[Differ[LocalDate]]) 41 | } 42 | 43 | test("LocalDateTime") { 44 | implicit val arb: Arbitrary[LocalDateTime] = Arbitrary(localDateTimeGen) 45 | assertOkIfValuesEqualProp(implicitly[Differ[LocalDateTime]]) && 46 | assertNotOkIfNotEqualProp(implicitly[Differ[LocalDateTime]]) && 47 | assertIsOkIfIgnoredProp(implicitly[Differ[LocalDateTime]]) 48 | } 49 | 50 | test("LocalTime") { 51 | implicit val arb: Arbitrary[LocalTime] = Arbitrary(localTimeGen) 52 | assertOkIfValuesEqualProp(implicitly[Differ[LocalTime]]) && 53 | assertNotOkIfNotEqualProp(implicitly[Differ[LocalTime]]) && 54 | assertIsOkIfIgnoredProp(implicitly[Differ[LocalTime]]) 55 | } 56 | 57 | test("Month") { 58 | implicit val arb: Arbitrary[Month] = Arbitrary(Gen.chooseNum(1, 12).map(Month.of)) 59 | assertOkIfValuesEqualProp(implicitly[Differ[Month]]) && 60 | assertNotOkIfNotEqualProp(implicitly[Differ[Month]]) && 61 | assertIsOkIfIgnoredProp(implicitly[Differ[Month]]) 62 | } 63 | 64 | test("MonthDay") { 65 | implicit val arb: Arbitrary[MonthDay] = Arbitrary(for { 66 | month <- Gen.choose[Int](1, 12) 67 | day <- Gen.choose[Int](1, 28) 68 | } yield MonthDay.of(month, day)) 69 | assertOkIfValuesEqualProp(implicitly[Differ[MonthDay]]) && 70 | assertNotOkIfNotEqualProp(implicitly[Differ[MonthDay]]) && 71 | assertIsOkIfIgnoredProp(implicitly[Differ[MonthDay]]) 72 | } 73 | 74 | test("OffsetDateTime") { 75 | implicit val arb: Arbitrary[OffsetDateTime] = Arbitrary(for { 76 | localDateTime <- localDateTimeGen 77 | zoneOffset <- zoneOffsetGen 78 | } yield OffsetDateTime.of(localDateTime, zoneOffset)) 79 | assertOkIfValuesEqualProp(implicitly[Differ[OffsetDateTime]]) && 80 | assertNotOkIfNotEqualProp(implicitly[Differ[OffsetDateTime]]) && 81 | assertIsOkIfIgnoredProp(implicitly[Differ[OffsetDateTime]]) 82 | } 83 | 84 | test("OffsetTime") { 85 | implicit val arb: Arbitrary[OffsetTime] = Arbitrary(for { 86 | localTime <- localTimeGen 87 | zoneOffset <- zoneOffsetGen 88 | } yield OffsetTime.of(localTime, zoneOffset)) 89 | assertOkIfValuesEqualProp(implicitly[Differ[OffsetTime]]) && 90 | assertNotOkIfNotEqualProp(implicitly[Differ[OffsetTime]]) && 91 | assertIsOkIfIgnoredProp(implicitly[Differ[OffsetTime]]) 92 | } 93 | 94 | test("Period") { 95 | implicit val arb: Arbitrary[Period] = Arbitrary(for { 96 | years <- Gen.posNum[Int] 97 | months <- Gen.choose[Int](1, 12) 98 | days <- Gen.choose[Int](1, 28) 99 | } yield Period.of(years, months, days)) 100 | assertOkIfValuesEqualProp(implicitly[Differ[Period]]) && 101 | assertNotOkIfNotEqualProp(implicitly[Differ[Period]]) && 102 | assertIsOkIfIgnoredProp(implicitly[Differ[Period]]) 103 | } 104 | 105 | test("Year") { 106 | implicit val arb: Arbitrary[Year] = Arbitrary(for { 107 | year <- Gen.posNum[Int] 108 | } yield Year.of(year)) 109 | assertOkIfValuesEqualProp(implicitly[Differ[Year]]) && 110 | assertNotOkIfNotEqualProp(implicitly[Differ[Year]]) && 111 | assertIsOkIfIgnoredProp(implicitly[Differ[Year]]) 112 | } 113 | 114 | test("YearMonth") { 115 | implicit val arb: Arbitrary[YearMonth] = Arbitrary(for { 116 | year <- Gen.posNum[Int] 117 | month <- Gen.choose(1, 12) 118 | } yield YearMonth.of(year, month)) 119 | assertOkIfValuesEqualProp(implicitly[Differ[YearMonth]]) && 120 | assertNotOkIfNotEqualProp(implicitly[Differ[YearMonth]]) && 121 | assertIsOkIfIgnoredProp(implicitly[Differ[YearMonth]]) 122 | } 123 | 124 | test("ZonedDateTime") { 125 | implicit val arb: Arbitrary[ZonedDateTime] = Arbitrary(for { 126 | localDateTime <- localDateTimeGen 127 | zoneId <- zoneIdGen 128 | } yield ZonedDateTime.of(localDateTime, zoneId)) 129 | assertOkIfValuesEqualProp(implicitly[Differ[ZonedDateTime]]) && 130 | assertNotOkIfNotEqualProp(implicitly[Differ[ZonedDateTime]]) && 131 | assertIsOkIfIgnoredProp(implicitly[Differ[ZonedDateTime]]) 132 | } 133 | 134 | test("ZoneId") { 135 | implicit val arb: Arbitrary[ZoneId] = Arbitrary(zoneIdGen) 136 | assertOkIfValuesEqualProp(implicitly[Differ[ZoneId]]) && 137 | assertNotOkIfNotEqualProp(implicitly[Differ[ZoneId]]) && 138 | assertIsOkIfIgnoredProp(implicitly[Differ[ZoneId]]) 139 | } 140 | 141 | test("ZoneOffset") { 142 | implicit val arb: Arbitrary[ZoneOffset] = Arbitrary(zoneOffsetGen) 143 | assertOkIfValuesEqualProp(implicitly[Differ[ZoneOffset]]) && 144 | assertNotOkIfNotEqualProp(implicitly[Differ[ZoneOffset]]) && 145 | assertIsOkIfIgnoredProp(implicitly[Differ[ZoneOffset]]) 146 | } 147 | 148 | lazy val zoneOffsetGen: Gen[ZoneOffset] = for { 149 | hours <- Gen.choose(-12, +12) 150 | } yield ZoneOffset.ofHours(hours) 151 | 152 | lazy val localTimeGen: Gen[LocalTime] = for { 153 | hours <- Gen.choose[Int](0, 23) 154 | minutes <- Gen.choose[Int](0, 59) 155 | seconds <- Gen.choose[Int](0, 59) 156 | nanos <- Gen.choose[Int](0, 1000000000 - 1) 157 | } yield LocalTime.of(hours, minutes, seconds, nanos) 158 | 159 | lazy val localDateTimeGen: Gen[LocalDateTime] = for { 160 | year <- Gen.posNum[Int] 161 | month <- Gen.choose[Int](1, 12) 162 | days <- Gen.choose[Int](1, 28) 163 | hours <- Gen.choose[Int](0, 23) 164 | minutes <- Gen.choose[Int](0, 59) 165 | seconds <- Gen.choose[Int](0, 59) 166 | nanos <- Gen.choose[Int](0, 1000000000 - 1) 167 | } yield LocalDateTime.of(year, month, days, hours, minutes, seconds, nanos) 168 | 169 | lazy val zoneIdGen: Gen[ZoneId] = Gen.oneOf( 170 | Seq( 171 | "America/Hermosillo", 172 | "America/Eirunepe", 173 | "America/St_Vincent", 174 | "America/Sao_Paulo", 175 | "Pacific/Tongatapu", 176 | "Asia/Tokyo", 177 | "Africa/Cairo", 178 | "Africa/Abidjan", 179 | "Africa/Brazzaville", 180 | ).map(ZoneId.of), 181 | ) 182 | } 183 | -------------------------------------------------------------------------------- /docs/docs/docs/ConfiguringDiffers.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: docs 3 | title: "Configuring Differs" 4 | permalink: docs/configuring-differs 5 | --- 6 | 7 | # Configuring Differs 8 | 9 | In Difflicious, Differs are built to be reconfigurable. This allows you to adapt an existing Differ for each test 10 | as needed. 11 | 12 | Difflicious also supports "deep configuration" where you can tweak how a particular sub-structure of a type is compared. 13 | with an intuitive API similar to the ones found in libraries like [diffx](https://github.com/softwaremill/diffx) and 14 | [Quicklens](https://github.com/softwaremill/quicklens). 15 | 16 | Differs are **immutable** - if you configure it it'll return a new Differ. 17 | 18 | ```scala mdoc:invisible 19 | import difflicious.Example._ 20 | ``` 21 | 22 | Code examples in this page assumes the following import: 23 | ```scala mdoc:silent 24 | import difflicious._ 25 | import difflicious.implicits._ 26 | ``` 27 | 28 | ## Basic Configuration 29 | 30 | ### Ignore and Unignore 31 | 32 | You can call `.ignore` or `.unignore` on all Differs. This will ignore their diff results and stop it from failing tests. 33 | 34 | ### Pair By 35 | 36 | For Differs of Seq/Set-like data structures, you can call `.pairBy` or `.pairByIndex` to change how elements of these 37 | data structures are paired up for comparison. 38 | 39 | ## Deep configuration using path expressions 40 | 41 | Difflicious supports configuring a subpart of a Differ with a complex type by using `.configure` which takes a "path expression" 42 | which you can use to express the path to the Differ you want to configure. 43 | 44 | | Differ Type | Allowed Paths | Explanation | 45 | | -- | -- | -- | 46 | | Seq | .each | Traverse down to the Differ used to compare the elements | 47 | | Set | .each | Traverse down to the Differ used to compare the elements | 48 | | Map | .each | Traverse down to the Differ used to compare the values of the Map | 49 | | Case Class | (any case class field) | Traverse down to the Differ for the specified sub type | 50 | | Sealed Trait | .subType[SomeSubType] | Traverse down to the Differ for the specified sub type | 51 | 52 | Some examples: 53 | 54 | ```scala mdoc:invisible 55 | sealed trait MySealedTrait 56 | case class SomeSubType(fieldInSubType: String) extends MySealedTrait 57 | 58 | object MySealedTrait { 59 | implicit val differ: Differ[MySealedTrait] = Differ.derived[MySealedTrait] 60 | } 61 | ``` 62 | 63 | ```scala mdoc:nest:silent 64 | val differ: Differ[Map[String, List[Person]]] = Differ[Map[String, List[Person]]] 65 | 66 | // Don't fail if peron's name is different. 67 | val differIgnoringPersonName = differ.ignoreAt(_.each.each.name) 68 | // .ignoreAt is just a shorthand for configure(...)(_.ignore) so this is equivalent 69 | val differIgnoringPersonName2 = differ.configure(_.each.each.name)(_.ignore) 70 | 71 | // When comparing List[Person], pair the elements by the Person's name 72 | val differPairingByPersonName = differ.configure(_.each)(_.pairBy(_.name)) 73 | 74 | // "Focusing" into the Differ for a subtype and ignoring a field 75 | val sealedTraitDiffer: Differ[List[MySealedTrait]] = Differ[List[MySealedTrait]] 76 | val differWithSubTypesFieldIgnored = sealedTraitDiffer.ignoreAt(_.each.subType[SomeSubType].fieldInSubType) 77 | ``` 78 | 79 | ## Replace differs 80 | 81 | You can completely replace the underlying differ at a path using `replace`. This is useful when you want to reuse an existing 82 | Differ you already have. 83 | 84 | ```scala mdoc:silent 85 | val mapDiffer: Differ[Map[String, List[Person]]] = Differ[Map[String, List[Person]]] 86 | val pairAndCompareByAge = Differ[List[Person]].pairBy(_.age).ignoreAt(_.each.name) 87 | val pairByName = Differ[List[Person]].pairBy(_.name) 88 | 89 | // Use this to compare each person list by age only 90 | mapDiffer.replace(_.each)(pairAndCompareByAge) 91 | 92 | // Use this to compare each person list paired by name 93 | mapDiffer.replace(_.each)(pairByName) 94 | ``` 95 | 96 | ## Unsafe API with `configureRaw` 97 | 98 | This is a low-level API that you shouldn't need in normal usage. All the nice in the previous sections calls this 99 | under the hood and it is exposed in case you really need it. 100 | 101 | `configureRaw` takes a stringly-typed path to configure the Differ and a raw `ConfigureOp`. 102 | While the API tries to detect errors, there is very little type safety and mistakes can lead to runtime exception. 103 | (For example, `configureRaw` won't stop you from replacing a Differ with a Differ of the wrong type) 104 | 105 | ```scala 106 | def configureRaw(path: ConfigurePath, operation: ConfigureOp): Either[DifferUpdateError, Differ[T]] 107 | ``` 108 | 109 | We need to provide: 110 | 111 | - A `path` parameter to "travsere" to the Differ you want to cnofigure. Can be the current Differ (`ConfigurePath.current`), or a Differ embedded inside it. 112 | - The type of configuration change you want to make e.g. Mark the Differ as `ignored` 113 | 114 | Let's look at some examples: 115 | 116 | ```scala mdoc:silent 117 | import difflicious.{Differ, ConfigureOp, ConfigurePath} 118 | ``` 119 | 120 | **Example: Changing diff of `List[Person]` to pair elements by `name` field** 121 | 122 | Let's say we want to compare the `List[Person]` independent of element order but instead match by `name` field... 123 | 124 | ```scala mdoc:silent 125 | val defaultDiffer: Differ[Map[String, List[Person]]] = Differ[Map[String, List[Person]]] 126 | val differPairByName: Differ[Map[String, List[Person]]] = defaultDiffer 127 | .configureRaw( 128 | ConfigurePath.of("each"), 129 | ConfigureOp.PairBy.ByFunc[Person, String](_.name) 130 | ).right.get 131 | 132 | // Try it! 133 | differPairByName.diff( 134 | Map( 135 | "Germany" -> List( 136 | Person("Bob", 55), 137 | Person("Alice", 55), 138 | ) 139 | ), 140 | Map( 141 | "Germany" -> List( 142 | Person("Alice", 56), 143 | Person("Bob", 55), 144 | ), 145 | "France" -> List.empty 146 | ) 147 | ) 148 | ``` 149 | 150 |
151 | Map(
152 |   "Germany" -> List(
153 |       Person(
154 |         name: "Bob",
155 |         age: 55,
156 |       ),
157 |       Person(
158 |         name: "Alice",
159 |         age: 55 -> 56,
160 |       ),
161 |     ),
162 |   "France" -> List(
163 |     ),
164 | )
165 | 
166 | 167 | **Example: Ignore a field in a Person when comparing** 168 | 169 | Let's say we don't want to take into account the name of the person when comparing... 170 | 171 | ```scala mdoc:silent 172 | val differPersonAgeIgnored: Differ[Map[String, List[Person]]] = defaultDiffer 173 | .configureRaw( 174 | ConfigurePath.of("each", "each", "age"), 175 | ConfigureOp.ignore 176 | ).right.get 177 | 178 | // Try it! 179 | differPersonAgeIgnored.diff( 180 | Map( 181 | "Germany" -> List( 182 | Person("Alice", 55), 183 | Person("Bob", 55), 184 | ) 185 | ), 186 | Map( 187 | "Germany" -> List( 188 | Person("Alice", 100), 189 | Person("Bob", 100), 190 | ), 191 | ) 192 | ) 193 | ``` 194 | 195 |
196 | Map(
197 |   "Germany" -> List(
198 |       Person(
199 |         name: "Alice",
200 |         age: [IGNORED],
201 |       ),
202 |       Person(
203 |         name: "Bob",
204 |         age: [IGNORED],
205 |       ),
206 |     ),
207 | )
208 | 
209 | 210 | When testing (e.g. assertNoDiff) the test would pass because the person's age is not considered in the comparison. 211 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala/difflicious/DifferConfigureSpec.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.ConfigureError.NonExistentField 4 | import difflicious.testutils._ 5 | import difflicious.testtypes._ 6 | import difflicious.implicits._ 7 | 8 | // Tests for configuring a Differ 9 | class DifferConfigureSpec extends munit.FunSuite { 10 | 11 | test("Differ#ignore works") { 12 | assertConsoleDiffOutput( 13 | CC.differ.ignore, 14 | CC(1, "s", 1.0), 15 | CC(1, "s", 2.0), 16 | grayIgnoredStr, 17 | ) 18 | } 19 | 20 | test("Differ#unignore works") { 21 | assertConsoleDiffOutput( 22 | CC.differ.ignore.unignore, 23 | CC(1, "s", 1.0), 24 | CC(1, "s", 2.0), 25 | s"""CC( 26 | | i: 1, 27 | | s: "s", 28 | | dd: ${R}1.0$X -> ${G}2.0$X 29 | |)""".stripMargin, 30 | ) 31 | } 32 | 33 | test("configure path allows 'each' to resolve underlying differ in a Map") { 34 | assertConsoleDiffOutput( 35 | Differ[Map[String, CC]].ignoreAt(_.each.dd), 36 | Map( 37 | "a" -> CC(1, "s", 1.0), 38 | ), 39 | Map( 40 | "a" -> CC(1, "s", 2.0), 41 | ), 42 | s"""Map( 43 | | "a" -> CC( 44 | | i: 1, 45 | | s: "s", 46 | | dd: $I[IGNORED]$X 47 | | ) 48 | |)""".stripMargin, 49 | ) 50 | } 51 | 52 | test("configure path allows 'each' to resolve underlying differ in a Seq") { 53 | assertConsoleDiffOutput( 54 | Differ[List[CC]].ignoreAt(_.each.dd), 55 | List( 56 | CC(1, "s", 1.0), 57 | ), 58 | List( 59 | CC(1, "s", 2.0), 60 | ), 61 | s"""List( 62 | | CC( 63 | | i: 1, 64 | | s: "s", 65 | | dd: $I[IGNORED]$X 66 | | ) 67 | |)""".stripMargin, 68 | ) 69 | } 70 | 71 | test("configure path allows 'each' to resolve underlying differ in a Set") { 72 | assertConsoleDiffOutput( 73 | Differ[Set[CC]].pairBy(_.i).ignoreAt(_.each.dd), 74 | Set( 75 | CC(1, "s", 1.0), 76 | ), 77 | Set( 78 | CC(1, "s", 2.0), 79 | ), 80 | s"""Set( 81 | | CC( 82 | | i: 1, 83 | | s: "s", 84 | | dd: $I[IGNORED]$X 85 | | ) 86 | |)""".stripMargin, 87 | ) 88 | } 89 | 90 | test("configure path can handle escaped sub-type and field names") { 91 | import Sealed.`Weird@Sub` 92 | assertConsoleDiffOutput( 93 | Differ[List[Sealed]].ignoreAt(_.each.subType[`Weird@Sub`].`weird@Field`), 94 | List( 95 | `Weird@Sub`(1, "a"), 96 | ), 97 | List( 98 | `Weird@Sub`(1, "x"), 99 | ), 100 | s"""List( 101 | | Weird@Sub( 102 | | i: 1, 103 | | weird@Field: $I[IGNORED]$X 104 | | ) 105 | |)""".stripMargin, 106 | ) 107 | } 108 | 109 | test("pairBy works with Seq") { 110 | assertConsoleDiffOutput( 111 | Differ[HasASeq[CC]].configure(_.seq)(_.pairBy(_.i)), 112 | HasASeq( 113 | Seq( 114 | CC(1, "s", 1.0), 115 | CC(2, "s", 2.0), 116 | ), 117 | ), 118 | HasASeq( 119 | Seq( 120 | CC(2, "s", 4.0), 121 | CC(1, "s", 2.0), 122 | ), 123 | ), 124 | s"""HasASeq( 125 | | seq: Seq( 126 | | CC( 127 | | i: 1, 128 | | s: "s", 129 | | dd: ${R}1.0$X -> ${G}2.0$X 130 | | ), 131 | | CC( 132 | | i: 2, 133 | | s: "s", 134 | | dd: ${R}2.0$X -> ${G}4.0$X 135 | | ) 136 | | ) 137 | |)""".stripMargin, 138 | ) 139 | } 140 | 141 | test("pairBy works with Set") { 142 | assertConsoleDiffOutput( 143 | Differ[Map[String, Set[CC]]].configure(x => x.each)(_.pairBy(_.i)), 144 | Map( 145 | "a" -> Set( 146 | CC(1, "s", 1.0), 147 | CC(2, "s", 2.0), 148 | ), 149 | ), 150 | Map( 151 | "a" -> Set( 152 | CC(2, "s", 4.0), 153 | CC(1, "s", 2.0), 154 | ), 155 | ), 156 | s"""Map( 157 | | "a" -> Set( 158 | | CC( 159 | | i: 1, 160 | | s: "s", 161 | | dd: ${R}1.0$X -> ${G}2.0$X 162 | | ), 163 | | CC( 164 | | i: 2, 165 | | s: "s", 166 | | dd: ${R}2.0$X -> ${G}4.0$X 167 | | ) 168 | | ) 169 | |)""".stripMargin, 170 | ) 171 | } 172 | 173 | test("'replace' for MapDiffer replaces value differ when step is 'each'") { 174 | val differ: Differ[Map[String, CC]] = Differ[Map[String, CC]].configure(_.each)(_.ignore) 175 | val differWithReplace = differ.replace[CC](_.each)(CC.differ) 176 | 177 | assertConsoleDiffOutput( 178 | differ, 179 | Map( 180 | "a" -> CC(1, "s", 1.0), 181 | ), 182 | Map( 183 | "a" -> CC(1, "s", 4.0), 184 | ), 185 | s"""Map( 186 | | "a" -> $grayIgnoredStr 187 | |)""".stripMargin, 188 | ) 189 | 190 | assertConsoleDiffOutput( 191 | differWithReplace, 192 | Map( 193 | "a" -> CC(1, "s", 1.0), 194 | ), 195 | Map( 196 | "a" -> CC(1, "s", 4.0), 197 | ), 198 | s"""Map( 199 | | "a" -> CC( 200 | | i: 1, 201 | | s: "s", 202 | | dd: ${R}1.0$X -> ${G}4.0$X 203 | | ) 204 | |)""".stripMargin, 205 | ) 206 | } 207 | 208 | test("'replace' for MapDiffer fails if step isn't 'each'") { 209 | assertEquals( 210 | Differ[Map[String, CC]] 211 | .configureRaw(ConfigurePath.of("nope"), ConfigureOp.TransformDiffer[CC](_ => CC.differ)), 212 | Left(NonExistentField(configurePathResolved("nope"), "MapDiffer")), 213 | ) 214 | } 215 | 216 | test("'replace' for SeqDiffer replaces ite differ when step is 'each'") { 217 | val differ: Differ[Seq[CC]] = Differ[Seq[CC]].configure(_.each)(_.ignore) 218 | val differWithReplace = differ.replace[CC](_.each)(CC.differ) 219 | 220 | assertConsoleDiffOutput( 221 | differ, 222 | Seq( 223 | CC(1, "s", 1.0), 224 | ), 225 | Seq( 226 | CC(1, "s", 4.0), 227 | ), 228 | s"""Seq( 229 | | $grayIgnoredStr 230 | |)""".stripMargin, 231 | ) 232 | 233 | assertConsoleDiffOutput( 234 | differWithReplace, 235 | Seq( 236 | CC(1, "s", 1.0), 237 | ), 238 | Seq( 239 | CC(1, "s", 4.0), 240 | ), 241 | s"""Seq( 242 | | CC( 243 | | i: 1, 244 | | s: "s", 245 | | dd: ${R}1.0$X -> ${G}4.0$X 246 | | ) 247 | |)""".stripMargin, 248 | ) 249 | } 250 | 251 | test("'replace' for SeqDiffer fails if step isn't 'each'") { 252 | assertEquals( 253 | Differ[Seq[CC]] 254 | .configureRaw(ConfigurePath.of("nope"), ConfigureOp.TransformDiffer[CC](_ => CC.differ)), 255 | Left(NonExistentField(configurePathResolved("nope"), "SeqDiffer")), 256 | ) 257 | } 258 | 259 | test("'replace' for SetDiffer replaces ite differ when step is 'each'") { 260 | val differ: Differ[Set[CC]] = Differ[Set[CC]].configure(_.each)(_.ignore) 261 | val differWithReplace = differ.replace[CC](_.each)(CC.differ) 262 | 263 | assertConsoleDiffOutput( 264 | differ, 265 | Set( 266 | CC(1, "s", 1.0), 267 | ), 268 | Set( 269 | CC(1, "s", 1.0), 270 | ), 271 | s"""Set( 272 | | $grayIgnoredStr 273 | |)""".stripMargin, 274 | ) 275 | 276 | assertConsoleDiffOutput( 277 | differWithReplace, 278 | Set( 279 | CC(1, "s", 1.0), 280 | ), 281 | Set( 282 | CC(1, "s", 1.0), 283 | ), 284 | s"""Set( 285 | | CC( 286 | | i: 1, 287 | | s: "s", 288 | | dd: 1.0 289 | | ) 290 | |)""".stripMargin, 291 | ) 292 | } 293 | 294 | test("'replace' for SeqDiffer fails if step isn't 'each'") { 295 | assertEquals( 296 | Differ[Set[CC]] 297 | .configureRaw(ConfigurePath.of("nope"), ConfigureOp.TransformDiffer[CC](_ => CC.differ)), 298 | Left(NonExistentField(configurePathResolved("nope"), "SetDiffer")), 299 | ) 300 | } 301 | 302 | private def configurePathResolved(path: String*): ConfigurePath = { 303 | ConfigurePath(path.toVector, List.empty) 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /modules/cats/src/test/scala/difflicious/cats/CatsDataDiffSpec.scala: -------------------------------------------------------------------------------- 1 | package difflicious.cats 2 | 3 | import munit.FunSuite 4 | import cats.data._ 5 | import cats.laws.discipline.arbitrary._ 6 | import difflicious.Differ 7 | import difflicious.testtypes.{CC, MapKey} 8 | import difflicious.testutils._ 9 | import difflicious.implicits._ 10 | import difflicious.cats.implicits._ 11 | 12 | class CatsDataDiffSpec extends FunSuite { 13 | test("NonEmptyMap: Has map-like diff result") { 14 | assertConsoleDiffOutput( 15 | Differ[NonEmptyMap[String, CC]], 16 | NonEmptyMap.of( 17 | "a" -> CC(1, "1", 1), 18 | "b" -> CC(2, "2", 2), 19 | ), 20 | NonEmptyMap.of( 21 | "a" -> CC(1, "x", 1), 22 | "b" -> CC(2, "2", 2), 23 | "c" -> CC(1, "x", 1), 24 | ), 25 | s"""NonEmptyMap( 26 | | "a" -> CC( 27 | | i: 1, 28 | | s: $R"1"$X -> $G"x"$X, 29 | | dd: 1.0 30 | | ), 31 | | "b" -> CC( 32 | | i: 2, 33 | | s: "2", 34 | | dd: 2.0 35 | | ), 36 | | $G"c"$X -> ${G}CC( 37 | | i: 1, 38 | | s: "x", 39 | | dd: 1.0 40 | | )$X 41 | |)""".stripMargin, 42 | ) 43 | } 44 | 45 | test("NonEmptyMap: Prop: isOk if equals") { 46 | assertOkIfValuesEqualProp[NonEmptyMap[MapKey, CC]](implicitly) 47 | } 48 | 49 | test("NonEmptyMap: Prop: isOk == false if not equal") { 50 | assertNotOkIfNotEqualProp[NonEmptyMap[MapKey, CC]](implicitly) 51 | } 52 | 53 | test("NonEmptyList: Has list-like diff result") { 54 | assertConsoleDiffOutput( 55 | Differ[NonEmptyList[CC]], 56 | NonEmptyList.of( 57 | CC(1, "1", 1), 58 | CC(2, "2", 2), 59 | ), 60 | NonEmptyList.of( 61 | CC(1, "2", 1), 62 | CC(2, "2", 2), 63 | CC(1, "x", 1), 64 | ), 65 | s"""NonEmptyList( 66 | | CC( 67 | | i: 1, 68 | | s: $R"1"$X -> $G"2"$X, 69 | | dd: 1.0 70 | | ), 71 | | CC( 72 | | i: 2, 73 | | s: "2", 74 | | dd: 2.0 75 | | ), 76 | | ${G}CC( 77 | | i: 1, 78 | | s: "x", 79 | | dd: 1.0 80 | | )$X 81 | |)""".stripMargin, 82 | ) 83 | } 84 | 85 | test("NonEmptyList: pairBy") { 86 | assertConsoleDiffOutput( 87 | Differ[NonEmptyList[CC]].pairBy(_.s), 88 | NonEmptyList.of( 89 | CC(1, "1", 1), 90 | CC(2, "2", 2), 91 | ), 92 | NonEmptyList.of( 93 | CC(2, "1", 2), 94 | CC(2, "3", 2), 95 | ), 96 | s"""NonEmptyList( 97 | | CC( 98 | | i: ${R}1$X -> ${G}2$X, 99 | | s: "1", 100 | | dd: ${R}1.0$X -> ${G}2.0$X 101 | | ), 102 | | ${R}CC( 103 | | i: 2, 104 | | s: "2", 105 | | dd: 2.0 106 | | )$X, 107 | | ${G}CC( 108 | | i: 2, 109 | | s: "3", 110 | | dd: 2.0 111 | | )$X 112 | |)""".stripMargin, 113 | ) 114 | } 115 | 116 | test("NonEmptyVector: Has list-like diff result") { 117 | assertConsoleDiffOutput( 118 | Differ[NonEmptyVector[CC]], 119 | NonEmptyVector.of( 120 | CC(1, "1", 1), 121 | CC(2, "2", 2), 122 | ), 123 | NonEmptyVector.of( 124 | CC(1, "2", 1), 125 | CC(2, "2", 2), 126 | CC(1, "x", 1), 127 | ), 128 | s"""NonEmptyVector( 129 | | CC( 130 | | i: 1, 131 | | s: $R"1"$X -> $G"2"$X, 132 | | dd: 1.0 133 | | ), 134 | | CC( 135 | | i: 2, 136 | | s: "2", 137 | | dd: 2.0 138 | | ), 139 | | ${G}CC( 140 | | i: 1, 141 | | s: "x", 142 | | dd: 1.0 143 | | )$X 144 | |)""".stripMargin, 145 | ) 146 | } 147 | 148 | test("NonEmptyVector: pairBy") { 149 | assertConsoleDiffOutput( 150 | Differ[NonEmptyVector[CC]].pairBy(_.s), 151 | NonEmptyVector.of( 152 | CC(1, "1", 1), 153 | CC(2, "2", 2), 154 | ), 155 | NonEmptyVector.of( 156 | CC(2, "1", 2), 157 | CC(2, "3", 2), 158 | ), 159 | s"""NonEmptyVector( 160 | | CC( 161 | | i: ${R}1$X -> ${G}2$X, 162 | | s: "1", 163 | | dd: ${R}1.0$X -> ${G}2.0$X 164 | | ), 165 | | ${R}CC( 166 | | i: 2, 167 | | s: "2", 168 | | dd: 2.0 169 | | )$X, 170 | | ${G}CC( 171 | | i: 2, 172 | | s: "3", 173 | | dd: 2.0 174 | | )$X 175 | |)""".stripMargin, 176 | ) 177 | } 178 | 179 | test("Chain: Has list-like diff result") { 180 | assertConsoleDiffOutput( 181 | Differ[Chain[CC]], 182 | Chain( 183 | CC(1, "1", 1), 184 | CC(2, "2", 2), 185 | ), 186 | Chain( 187 | CC(1, "2", 1), 188 | CC(2, "2", 2), 189 | CC(1, "x", 1), 190 | ), 191 | s"""Chain( 192 | | CC( 193 | | i: 1, 194 | | s: $R"1"$X -> $G"2"$X, 195 | | dd: 1.0 196 | | ), 197 | | CC( 198 | | i: 2, 199 | | s: "2", 200 | | dd: 2.0 201 | | ), 202 | | ${G}CC( 203 | | i: 1, 204 | | s: "x", 205 | | dd: 1.0 206 | | )$X 207 | |)""".stripMargin, 208 | ) 209 | } 210 | 211 | test("Chain: pairBy") { 212 | assertConsoleDiffOutput( 213 | Differ[Chain[CC]].pairBy(_.s), 214 | Chain( 215 | CC(1, "1", 1), 216 | CC(2, "2", 2), 217 | ), 218 | Chain( 219 | CC(2, "1", 2), 220 | CC(2, "3", 2), 221 | ), 222 | s"""Chain( 223 | | CC( 224 | | i: ${R}1$X -> ${G}2$X, 225 | | s: "1", 226 | | dd: ${R}1.0$X -> ${G}2.0$X 227 | | ), 228 | | ${R}CC( 229 | | i: 2, 230 | | s: "2", 231 | | dd: 2.0 232 | | )$X, 233 | | ${G}CC( 234 | | i: 2, 235 | | s: "3", 236 | | dd: 2.0 237 | | )$X 238 | |)""".stripMargin, 239 | ) 240 | } 241 | 242 | test("NonEmptyChain: Has list-like diff result") { 243 | assertConsoleDiffOutput( 244 | Differ[NonEmptyChain[CC]], 245 | NonEmptyChain( 246 | CC(1, "1", 1), 247 | CC(2, "2", 2), 248 | ), 249 | NonEmptyChain( 250 | CC(1, "2", 1), 251 | CC(2, "2", 2), 252 | CC(1, "x", 1), 253 | ), 254 | s"""NonEmptyChain( 255 | | CC( 256 | | i: 1, 257 | | s: $R"1"$X -> $G"2"$X, 258 | | dd: 1.0 259 | | ), 260 | | CC( 261 | | i: 2, 262 | | s: "2", 263 | | dd: 2.0 264 | | ), 265 | | ${G}CC( 266 | | i: 1, 267 | | s: "x", 268 | | dd: 1.0 269 | | )$X 270 | |)""".stripMargin, 271 | ) 272 | } 273 | 274 | test("NonEmptyChain: pairBy") { 275 | assertConsoleDiffOutput( 276 | Differ[NonEmptyChain[CC]].pairBy(_.s), 277 | NonEmptyChain( 278 | CC(1, "1", 1), 279 | CC(2, "2", 2), 280 | ), 281 | NonEmptyChain( 282 | CC(2, "1", 2), 283 | CC(2, "3", 2), 284 | ), 285 | s"""NonEmptyChain( 286 | | CC( 287 | | i: ${R}1$X -> ${G}2$X, 288 | | s: "1", 289 | | dd: ${R}1.0$X -> ${G}2.0$X 290 | | ), 291 | | ${R}CC( 292 | | i: 2, 293 | | s: "2", 294 | | dd: 2.0 295 | | )$X, 296 | | ${G}CC( 297 | | i: 2, 298 | | s: "3", 299 | | dd: 2.0 300 | | )$X 301 | |)""".stripMargin, 302 | ) 303 | } 304 | 305 | test("NonEmptySet: Has set-like diff result") { 306 | assertConsoleDiffOutput( 307 | Differ[NonEmptySet[CC]], 308 | NonEmptySet.of( 309 | CC(1, "1", 1), 310 | CC(2, "2", 2), 311 | ), 312 | NonEmptySet.of( 313 | CC(1, "2", 1), 314 | CC(2, "2", 2), 315 | CC(1, "x", 1), 316 | ), 317 | s"""NonEmptySet( 318 | | ${R}CC( 319 | | i: 1, 320 | | s: "1", 321 | | dd: 1.0 322 | | )$X, 323 | | CC( 324 | | i: 2, 325 | | s: "2", 326 | | dd: 2.0 327 | | ), 328 | | ${G}CC( 329 | | i: 1, 330 | | s: "2", 331 | | dd: 1.0 332 | | )$X, 333 | | ${G}CC( 334 | | i: 1, 335 | | s: "x", 336 | | dd: 1.0 337 | | )$X 338 | |)""".stripMargin, 339 | ) 340 | } 341 | 342 | test("NonEmptySet: with pairBy") { 343 | assertConsoleDiffOutput( 344 | Differ[NonEmptySet[CC]].pairBy(_.i), 345 | NonEmptySet.of( 346 | CC(1, "1", 1), 347 | CC(2, "2", 2), 348 | ), 349 | NonEmptySet.of( 350 | CC(1, "2", 1), 351 | CC(3, "3", 3), 352 | ), 353 | s"""NonEmptySet( 354 | | CC( 355 | | i: 1, 356 | | s: $R"1"$X -> $G"2"$X, 357 | | dd: 1.0 358 | | ), 359 | | ${R}CC( 360 | | i: 2, 361 | | s: "2", 362 | | dd: 2.0 363 | | )$X, 364 | | ${G}CC( 365 | | i: 3, 366 | | s: "3", 367 | | dd: 3.0 368 | | )$X 369 | |)""".stripMargin, 370 | ) 371 | } 372 | 373 | } 374 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /modules/coretest/src/test/scala/difflicious/DifferSpec.scala: -------------------------------------------------------------------------------- 1 | package difflicious 2 | 3 | import difflicious.ConfigureError.InvalidConfigureOp 4 | import munit.ScalaCheckSuite 5 | import difflicious.testutils._ 6 | import difflicious.testtypes._ 7 | import difflicious.implicits._ 8 | import difflicious.internal.EitherGetSyntax._ 9 | 10 | import scala.collection.immutable.HashSet 11 | 12 | class DifferSpec extends ScalaCheckSuite with ScalaVersionDependentTests { 13 | test("NumericDiffer: configure fails if path is not terminal") { 14 | assertEquals( 15 | Differ[Int].configureRaw(ConfigurePath.of("nono"), ConfigureOp.ignore), 16 | Left(ConfigureError.PathTooLong(ConfigurePath(Vector("nono"), List.empty))), 17 | ) 18 | } 19 | 20 | test("NumericDiffer: configure fails if differ op is SetIgnore") { 21 | assertEquals( 22 | Differ[Int].configureRaw(ConfigurePath.current, ConfigureOp.PairBy.Index), 23 | Left( 24 | ConfigureError 25 | .InvalidConfigureOp(ConfigurePath(Vector.empty, List.empty), ConfigureOp.PairBy.Index, "NumericDiffer"), 26 | ), 27 | ) 28 | } 29 | 30 | test("EqualsDiffer: return Both/ObtainedOnly/ExpectedOnly depending on whether both sides are present in diff") { 31 | assertConsoleDiffOutput( 32 | Differ.mapDiffer[Map, String, EqClass], 33 | Map( 34 | "a" -> EqClass(1), 35 | "b" -> EqClass(2), 36 | ), 37 | Map( 38 | "a" -> EqClass(1), 39 | "c" -> EqClass(3), 40 | ), 41 | s"""Map( 42 | | $R"b"$X -> ${R}EqClass(2)${X}, 43 | | "a" -> EqClass(1), 44 | | $G"c"$X -> ${G}EqClass(3)${X} 45 | |)""".stripMargin, 46 | ) 47 | } 48 | 49 | test("EqualsDiffer: ObtainedOnly#isOk should always be false") { 50 | assertEquals(EqClass.differ.diff(DiffInput.ObtainedOnly(EqClass(1))).isOk, false) 51 | } 52 | 53 | test("EqualsDiffer: ObtainedOnly#isOk should always be false") { 54 | assertEquals(EqClass.differ.diff(DiffInput.ExpectedOnly(EqClass(1))).isOk, false) 55 | } 56 | 57 | test("EqualsDiffer: configure fails if path is not terminal") { 58 | assertEquals( 59 | EqClass.differ.configureRaw(ConfigurePath.of("asdf"), ConfigureOp.ignore), 60 | Left(ConfigureError.PathTooLong(ConfigurePath(Vector("asdf"), List.empty))), 61 | ) 62 | } 63 | 64 | test("EqualsDiffer: configure fails if op is not setting ignore") { 65 | assertEquals( 66 | EqClass.differ.configureRaw(ConfigurePath.current, ConfigureOp.PairBy.Index), 67 | Left(InvalidConfigureOp(ConfigurePath(Vector.empty, List.empty), ConfigureOp.PairBy.Index, "EqualsDiffer")), 68 | ) 69 | } 70 | 71 | test("EqualsDiffer: isOk == true if two values are equal") { 72 | assertOkIfValuesEqualProp(EqClass.differ) 73 | } 74 | 75 | test("EqualsDiffer: isOk == false if two values are NOT equal") { 76 | assertNotOkIfNotEqualProp(EqClass.differ) 77 | } 78 | 79 | test("EqualsDiffer: isOk always true if differ is marked ignored") { 80 | assertIsOkIfIgnoredProp(EqClass.differ) 81 | } 82 | 83 | test("Tuple2: isOk == true if two values are equal") { 84 | assertOkIfValuesEqualProp(Differ[(String, CC)]) 85 | } 86 | 87 | test("Tuple2: isOk == false if two values are NOT equal") { 88 | assertNotOkIfNotEqualProp(Differ[(String, CC)]) 89 | } 90 | 91 | test("Tuple2: isOk always true if differ is marked ignored") { 92 | assertIsOkIfIgnoredProp(Differ[(CC, Int)]) 93 | } 94 | 95 | test("Tuple3: isOk == true if two values are equal") { 96 | assertOkIfValuesEqualProp(Differ[Tuple3[String, CC, Int]]) 97 | } 98 | 99 | test("Tuple3: isOk == false if two values are NOT equal") { 100 | assertNotOkIfNotEqualProp(Differ[Tuple3[String, CC, Int]]) 101 | } 102 | 103 | test("Tuple3: isOk always true if differ is marked ignored") { 104 | assertIsOkIfIgnoredProp(Differ[Tuple3[String, CC, Int]]) 105 | } 106 | 107 | test("Tuple3: compared like a record") { 108 | assertConsoleDiffOutput( 109 | Differ[(String, Int, CC)], 110 | Tuple3("asdf", 1, CC(1, "1", 1.0)), 111 | Tuple3("s", 2, CC(2, "2", 3.0)), 112 | s"""Tuple3( 113 | | _1: $R"asdf"$X -> $G"s"$X, 114 | | _2: ${R}1$X -> ${G}2$X, 115 | | _3: CC( 116 | | i: ${R}1$X -> ${G}2$X, 117 | | s: ${R}"1"$X -> ${G}"2"$X, 118 | | dd: ${R}1.0$X -> ${G}3.0$X 119 | | ) 120 | |)""".stripMargin, 121 | ) 122 | } 123 | 124 | test("Option: fail if one is Some and one is None") { 125 | assertConsoleDiffOutput( 126 | Differ[Option[CC]], 127 | Some(CC(2, "2", 3.0)), 128 | None, 129 | s"""${R}Some$X != ${G}None$X 130 | |${R}=== Obtained === 131 | |Some( 132 | | value: CC( 133 | | i: 2, 134 | | s: "2", 135 | | dd: 3.0 136 | | ) 137 | |)$X 138 | |${G}=== Expected === 139 | |None( 140 | |)$X""".stripMargin, 141 | ) 142 | } 143 | 144 | test("Option: isOk == true if two values are equal") { 145 | assertOkIfValuesEqualProp(Differ[Option[CC]]) 146 | } 147 | 148 | test("Option: isOk == false if two values are NOT equal") { 149 | assertNotOkIfNotEqualProp(Differ[Option[CC]]) 150 | } 151 | 152 | test("Option: isOk always true if differ is marked ignored") { 153 | assertIsOkIfIgnoredProp(Differ[Option[CC]]) 154 | } 155 | 156 | test("Either: fail if one is Some and one is None") { 157 | assertConsoleDiffOutput( 158 | Differ[Either[String, CC]], 159 | Right(CC(2, "2", 3.0)), 160 | Left("nope"), 161 | s"""${R}Right$X != ${G}Left$X 162 | |${R}=== Obtained === 163 | |Right( 164 | | value: CC( 165 | | i: 2, 166 | | s: "2", 167 | | dd: 3.0 168 | | ) 169 | |)$X 170 | |${G}=== Expected === 171 | |Left( 172 | | value: "nope" 173 | |)$X""".stripMargin, 174 | ) 175 | } 176 | 177 | test("Either: isOk == true if two values are equal") { 178 | assertOkIfValuesEqualProp(Differ[Either[String, CC]]) 179 | } 180 | 181 | test("Either: isOk == false if two values are NOT equal") { 182 | assertNotOkIfNotEqualProp(Differ[Either[String, CC]]) 183 | } 184 | 185 | test("Either: isOk always true if differ is marked ignored") { 186 | assertIsOkIfIgnoredProp(Differ[Either[String, CC]]) 187 | } 188 | 189 | test("Map: isOk == true if two values are equal") { 190 | assertOkIfValuesEqualProp(Differ.mapDiffer[Map, MapKey, CC]) 191 | } 192 | 193 | test("Map: isOk == false if two values are NOT equal") { 194 | assertNotOkIfNotEqualProp(Differ.mapDiffer[Map, MapKey, CC]) 195 | } 196 | 197 | test("Map: isOk always true if differ is marked ignored") { 198 | assertIsOkIfIgnoredProp(Differ.mapDiffer[Map, MapKey, CC]) 199 | } 200 | 201 | test("Map diff shows both matched entries (based on key equals) and also one-side-only entries") { 202 | assertConsoleDiffOutput( 203 | Differ.mapDiffer[Map, MapKey, CC].configureRaw(ConfigurePath.of("each", "i"), ConfigureOp.ignore).unsafeGet, 204 | Map( 205 | MapKey(1, "s") -> CC(1, "s1", 1), 206 | MapKey(2, "sa") -> CC(2, "s2", 2), 207 | MapKey(4, "diff") -> CC(1, "s4", 1), 208 | ), 209 | Map( 210 | MapKey(1, "s") -> CC(1, "s1", 1), 211 | MapKey(4, "diff") -> CC(1, "sx", 1), 212 | MapKey(3, "se") -> CC(3, "s3", 2), 213 | ), 214 | s"""Map( 215 | | ${R}MapKey(2,sa)$X -> ${R}CC( 216 | | i: $justIgnoredStr, 217 | | s: "s2", 218 | | dd: 2.0 219 | | )$X, 220 | | MapKey(1,s) -> CC( 221 | | i: $grayIgnoredStr, 222 | | s: "s1", 223 | | dd: 1.0 224 | | ), 225 | | MapKey(4,diff) -> CC( 226 | | i: $grayIgnoredStr, 227 | | s: $R"s4"$X -> $G"sx"$X, 228 | | dd: 1.0 229 | | ), 230 | | ${G}MapKey(3,se)$X -> ${G}CC( 231 | | i: $justIgnoredStr, 232 | | s: "s3", 233 | | dd: 2.0 234 | | )${X} 235 | |)""".stripMargin, 236 | ) 237 | } 238 | 239 | test("Map: When only 'obtained' is provided when diffing") { 240 | assertConsoleDiffOutput( 241 | Differ[List[Map[MapKey, CC]]], 242 | List( 243 | Map( 244 | MapKey(1, "s") -> CC(1, "s1", 1), 245 | ), 246 | ), 247 | List.empty, 248 | s"""List( 249 | | ${R}Map( 250 | | MapKey(1,s) -> CC( 251 | | i: 1, 252 | | s: "s1", 253 | | dd: 1.0 254 | | ) 255 | | )$X 256 | |)""".stripMargin, 257 | ) 258 | } 259 | 260 | test("Map: When only 'expected' is provided when diffing") { 261 | assertConsoleDiffOutput( 262 | Differ[List[Map[MapKey, CC]]], 263 | List.empty, 264 | List( 265 | Map( 266 | MapKey(1, "s") -> CC(1, "s1", 1), 267 | ), 268 | ), 269 | s"""List( 270 | | ${G}Map( 271 | | MapKey(1,s) -> CC( 272 | | i: 1, 273 | | s: "s1", 274 | | dd: 1.0 275 | | ) 276 | | )$X 277 | |)""".stripMargin, 278 | ) 279 | } 280 | 281 | test("Map: Allow updating value differs using the path 'each'") { 282 | val differ = Differ[Map[String, List[CC]]] 283 | .configureRaw(ConfigurePath.of("each"), ConfigureOp.PairBy.ByFunc[CC, Int](_.i)) 284 | .unsafeGet 285 | val diffResult = differ.diff( 286 | Map( 287 | "a" -> List( 288 | CC(1, "1", 1), 289 | CC(2, "2", 2), 290 | ), 291 | ), 292 | Map( 293 | "a" -> List( 294 | CC(2, "2", 2), 295 | CC(1, "1", 1), 296 | ), 297 | ), 298 | ) 299 | assert(diffResult.isOk) 300 | } 301 | 302 | test("Map: configureRaw fails if field name isn't 'each'") { 303 | assertEquals( 304 | Differ[Map[String, String]].configureRaw(ConfigurePath.of("nono"), ConfigureOp.ignore), 305 | Left(ConfigureError.NonExistentField(ConfigurePath(Vector("nono"), List.empty), "MapDiffer")), 306 | ) 307 | } 308 | 309 | test("Map: configureRaw fails if operation isn't ignore") { 310 | assertEquals( 311 | Differ[Map[String, String]].configureRaw(ConfigurePath.current, ConfigureOp.PairBy.Index), 312 | Left(InvalidConfigureOp(ConfigurePath(Vector.empty, List.empty), ConfigureOp.PairBy.Index, "MapDiffer")), 313 | ) 314 | } 315 | 316 | test("Seq: isOk == true if two values are equal") { 317 | assertOkIfValuesEqualProp(Differ.seqDiffer[List, CC]) 318 | } 319 | 320 | test("Seq: isOk == false if two values are not equal") { 321 | assertNotOkIfNotEqualProp(Differ.seqDiffer[List, CC]) 322 | } 323 | 324 | test("Seq: isOk always true if differ is marked ignored") { 325 | assertIsOkIfIgnoredProp(Differ.seqDiffer[Seq, CC]) 326 | } 327 | 328 | test("Seq: match entries base on item index by default") { 329 | assertConsoleDiffOutput( 330 | Differ 331 | .seqDiffer[List, CC] 332 | .configureRaw(ConfigurePath.of("each", "dd"), ConfigureOp.ignore) 333 | .unsafeGet, 334 | List( 335 | CC(1, "s1", 1), 336 | CC(2, "s2", 2), 337 | CC(3, "s2", 2), 338 | ), 339 | List( 340 | CC(1, "s2", 1), 341 | CC(2, "s1", 2), 342 | CC(3, "s2", 2), 343 | ), 344 | s"""List( 345 | | CC( 346 | | i: 1, 347 | | s: $R"s1"$X -> $G"s2"$X, 348 | | dd: $grayIgnoredStr 349 | | ), 350 | | CC( 351 | | i: 2, 352 | | s: $R"s2"$X -> $G"s1"$X, 353 | | dd: $grayIgnoredStr 354 | | ), 355 | | CC( 356 | | i: 3, 357 | | s: "s2", 358 | | dd: $grayIgnoredStr 359 | | ) 360 | |)""".stripMargin, 361 | ) 362 | } 363 | 364 | test("Seq: with alternative pairBy should match by the resolved value instead of index") { 365 | assertConsoleDiffOutput( 366 | Differ 367 | .seqDiffer[List, CC] 368 | .pairBy(_.i), 369 | List( 370 | CC(1, "s1", 1), 371 | CC(2, "s2", 2), 372 | CC(3, "s2", 3), 373 | ), 374 | List( 375 | CC(2, "s1", 2), 376 | CC(4, "s2", 4), 377 | CC(1, "s2", 1), 378 | ), 379 | s"""List( 380 | | CC( 381 | | i: 1, 382 | | s: $R"s1"$X -> $G"s2"$X, 383 | | dd: 1.0 384 | | ), 385 | | CC( 386 | | i: 2, 387 | | s: $R"s2"$X -> $G"s1"$X, 388 | | dd: 2.0 389 | | ), 390 | | ${R}CC( 391 | | i: 3, 392 | | s: "s2", 393 | | dd: 3.0 394 | | )${X}, 395 | | ${G}CC( 396 | | i: 4, 397 | | s: "s2", 398 | | dd: 4.0 399 | | )${X} 400 | |)""".stripMargin, 401 | ) 402 | } 403 | 404 | test("Seq: Can set pairBy to match by index again") { 405 | assertConsoleDiffOutput( 406 | Differ 407 | .seqDiffer[List, Int] 408 | .pairBy(identity) 409 | .pairByIndex, 410 | List( 411 | 1, 412 | 2, 413 | ), 414 | List( 415 | 2, 416 | 1, 417 | ), 418 | s"""List( 419 | | ${R}1$X -> ${G}2$X, 420 | | ${R}2$X -> ${G}1$X 421 | |)""".stripMargin, 422 | ) 423 | } 424 | 425 | test("Seq: Only 'obtained' is provided when diffing") { 426 | assertConsoleDiffOutput( 427 | Differ[Map[String, List[Int]]], 428 | Map( 429 | "a" -> List(1, 2, 3), 430 | ), 431 | Map.empty[String, List[Int]], 432 | s"""Map( 433 | | $R"a"$X -> ${R}List( 434 | | 1, 435 | | 2, 436 | | 3 437 | | )$X 438 | |)""".stripMargin, 439 | ) 440 | } 441 | 442 | test("Seq: Only 'expected' is provided when diffing") { 443 | assertConsoleDiffOutput( 444 | Differ[Map[String, List[Int]]], 445 | Map.empty[String, List[Int]], 446 | Map( 447 | "a" -> List(1, 2, 3), 448 | ), 449 | s"""Map( 450 | | $G"a"$X -> ${G}List( 451 | | 1, 452 | | 2, 453 | | 3 454 | | )$X 455 | |)""".stripMargin, 456 | ) 457 | } 458 | 459 | test("Seq: Allow modifying element differs using the path 'each'") { 460 | val differ = Differ[List[CC]] 461 | .configureRaw(ConfigurePath.of("each", "i"), ConfigureOp.ignore) 462 | .unsafeGet 463 | val diffResult = differ.diff( 464 | List( 465 | CC( 466 | 1, 467 | "2", 468 | 3.0, 469 | ), 470 | ), 471 | List( 472 | CC( 473 | 2, 474 | "2", 475 | 3.0, 476 | ), 477 | ), 478 | ) 479 | assert(diffResult.isOk) 480 | } 481 | 482 | test("Seq: configureRaw fails if field name isn't 'each'") { 483 | assertEquals( 484 | Differ[Seq[String]].configureRaw(ConfigurePath.of("nono"), ConfigureOp.ignore), 485 | Left(ConfigureError.NonExistentField(ConfigurePath(Vector("nono"), List.empty), "SeqDiffer")), 486 | ) 487 | } 488 | 489 | test("Set: isOk == true if two values are equal") { 490 | assertOkIfValuesEqualProp(Differ.setDiffer[Set, CC]) 491 | } 492 | 493 | test("Set: isOk == false if two values are not equal") { 494 | assertNotOkIfNotEqualProp(Differ.setDiffer[Set, CC]) 495 | } 496 | 497 | test("Set: isOk always true if differ is marked ignored") { 498 | assertIsOkIfIgnoredProp(Differ.setDiffer[Set, CC]) 499 | } 500 | 501 | test("Set: match entries base on item identity by default") { 502 | assertConsoleDiffOutput( 503 | Differ 504 | .setDiffer[Set, CC] 505 | .configureRaw(ConfigurePath.of("each", "dd"), ConfigureOp.ignore) 506 | .unsafeGet, 507 | Set( 508 | CC(1, "s1", 1), 509 | CC(2, "s2", 2), 510 | CC(3, "s2", 2), 511 | ), 512 | Set( 513 | CC(1, "s2", 1), 514 | CC(2, "s1", 2), 515 | CC(3, "s2", 2), 516 | ), 517 | s"""Set( 518 | | ${R}CC( 519 | | i: 1, 520 | | s: "s1", 521 | | dd: $justIgnoredStr 522 | | )$X, 523 | | ${R}CC( 524 | | i: 2, 525 | | s: "s2", 526 | | dd: $justIgnoredStr 527 | | )$X, 528 | | CC( 529 | | i: 3, 530 | | s: "s2", 531 | | dd: $grayIgnoredStr 532 | | ), 533 | | ${G}CC( 534 | | i: 1, 535 | | s: "s2", 536 | | dd: $justIgnoredStr 537 | | )${X}, 538 | | ${G}CC( 539 | | i: 2, 540 | | s: "s1", 541 | | dd: $justIgnoredStr 542 | | )$X 543 | |)""".stripMargin, 544 | ) 545 | } 546 | 547 | test("Set: When only 'obtained' is provided when diffing") { 548 | assertConsoleDiffOutput( 549 | Differ[List[Set[Int]]], 550 | List( 551 | Set(1, 2), 552 | ), 553 | List.empty, 554 | s"""List( 555 | | ${R}Set( 556 | | 1, 557 | | 2 558 | | )$X 559 | |)""".stripMargin, 560 | ) 561 | } 562 | 563 | test("Set: When only 'expected' is provided when diffing") { 564 | assertConsoleDiffOutput( 565 | Differ[List[Set[Int]]], 566 | List.empty, 567 | List( 568 | Set(1, 2), 569 | ), 570 | s"""List( 571 | | ${G}Set( 572 | | 1, 573 | | 2 574 | | )$X 575 | |)""".stripMargin, 576 | ) 577 | } 578 | 579 | test("Set: Allow modifying element differs using the path 'each'") { 580 | val differ = Differ[HashSet[CC]] 581 | .configureRaw(ConfigurePath.of("each", "i"), ConfigureOp.ignore) 582 | .flatMap( 583 | _.configureRaw(ConfigurePath.current, ConfigureOp.PairBy.ByFunc[CC, String](_.s)), 584 | ) 585 | .unsafeGet 586 | val diffResult = differ.diff( 587 | HashSet( 588 | CC( 589 | 1, 590 | "2", 591 | 3.0, 592 | ), 593 | ), 594 | HashSet( 595 | CC( 596 | 2, 597 | "2", 598 | 3.0, 599 | ), 600 | ), 601 | ) 602 | assert(diffResult.isOk) 603 | } 604 | 605 | test("Set: Update fails if field name isn't 'each'") { 606 | assertEquals( 607 | Differ[Set[String]].configureRaw(ConfigurePath.of("nono"), ConfigureOp.ignore), 608 | Left(ConfigureError.NonExistentField(ConfigurePath(Vector("nono"), List.empty), "SetDiffer")), 609 | ) 610 | } 611 | 612 | test("Set: errors when trying to update the set to match by index (since Set has no inherent order)") { 613 | assertEquals( 614 | Differ.setDiffer[Set, CC].configureRaw(ConfigurePath.current, ConfigureOp.PairBy.Index), 615 | Left( 616 | ConfigureError 617 | .InvalidConfigureOp(ConfigurePath(Vector.empty, List.empty), ConfigureOp.PairBy.Index, "SetDiffer"), 618 | ), 619 | ) 620 | } 621 | 622 | test("Set: with alternative pairBy should match by the resolved value instead of index") { 623 | assertConsoleDiffOutput( 624 | Differ 625 | .setDiffer[Set, CC] 626 | .pairBy(_.i), 627 | Set( 628 | CC(1, "s1", 1), 629 | CC(2, "s2", 2), 630 | CC(3, "s2", 3), 631 | ), 632 | Set( 633 | CC(2, "s1", 2), 634 | CC(4, "s2", 4), 635 | CC(1, "s2", 1), 636 | ), 637 | s"""Set( 638 | | CC( 639 | | i: 1, 640 | | s: $R"s1"$X -> $G"s2"$X, 641 | | dd: 1.0 642 | | ), 643 | | CC( 644 | | i: 2, 645 | | s: $R"s2"$X -> $G"s1"$X, 646 | | dd: 2.0 647 | | ), 648 | | ${R}CC( 649 | | i: 3, 650 | | s: "s2", 651 | | dd: 3.0 652 | | )${X}, 653 | | ${G}CC( 654 | | i: 4, 655 | | s: "s2", 656 | | dd: 4.0 657 | | )${X} 658 | |)""".stripMargin, 659 | ) 660 | } 661 | 662 | test("Record: isOk == true if two values are equal") { 663 | assertOkIfValuesEqualProp(CC.differ) 664 | } 665 | 666 | test("Record: isOk == false if two values are not equal") { 667 | assertNotOkIfNotEqualProp(CC.differ) 668 | } 669 | 670 | test("Record: isOk always true if differ is marked ignored") { 671 | assertIsOkIfIgnoredProp(CC.differ) 672 | } 673 | 674 | test("Record: Attempting to update nonexistent field fails") { 675 | assertEquals( 676 | CC.differ.configureRaw(ConfigurePath.of("nonexistent"), ConfigureOp.ignore), 677 | Left( 678 | ConfigureError 679 | .NonExistentField(ConfigurePath(Vector("nonexistent"), List.empty), "RecordDiffer"), 680 | ), 681 | ) 682 | } 683 | 684 | test("Record: Trying to update the differ with PairBy op should fail") { 685 | assertEquals( 686 | CC.differ.configureRaw(ConfigurePath.current, ConfigureOp.PairBy.Index), 687 | Left( 688 | ConfigureError 689 | .InvalidConfigureOp( 690 | ConfigurePath(Vector.empty, List.empty), 691 | ConfigureOp.PairBy.Index, 692 | "RecordDiffer", 693 | ), 694 | ), 695 | ) 696 | } 697 | 698 | test("Record: ignoreFieldByNameOrFail succeeds if field exists") { 699 | assertEquals( 700 | CC.differ.configureRaw(ConfigurePath.current, ConfigureOp.PairBy.Index), 701 | Left( 702 | ConfigureError 703 | .InvalidConfigureOp( 704 | ConfigurePath(Vector.empty, List.empty), 705 | ConfigureOp.PairBy.Index, 706 | "RecordDiffer", 707 | ), 708 | ), 709 | ) 710 | } 711 | 712 | test("Sealed trait: should display obtained and expected types when mismatch") { 713 | assertConsoleDiffOutput( 714 | Sealed.differ, 715 | Sealed.Sub1(1), 716 | Sealed.Sub2(1.0), 717 | s"""${R}Sub1$X != ${G}Sub2${X} 718 | |${R}=== Obtained === 719 | |Sub1( 720 | | i: 1 721 | |)$X 722 | |$G=== Expected === 723 | |Sub2( 724 | | d: 1.0 725 | |)$X""".stripMargin, 726 | ) 727 | } 728 | 729 | test("Sealed trait: should display obtained and expected types when mismatch") { 730 | assertConsoleDiffOutput( 731 | Sealed.differ, 732 | Sealed.Sub1(1), 733 | Sealed.Sub2(1), 734 | s"""${R}Sub1$X != ${G}Sub2${X} 735 | |${R}=== Obtained === 736 | |Sub1( 737 | | i: 1 738 | |)$X 739 | |$G=== Expected === 740 | |Sub2( 741 | | d: 1.0 742 | |)$X""".stripMargin, 743 | ) 744 | } 745 | 746 | test("Sealed trait: isOk == true if two values are equal") { 747 | assertOkIfValuesEqualProp(Sealed.differ) 748 | } 749 | 750 | test("Sealed trait: isOk == false if two values are NOT equal") { 751 | assertNotOkIfNotEqualProp(Sealed.differ) 752 | } 753 | 754 | test("Sealed trait: isOk always true if differ is marked ignored") { 755 | assertIsOkIfIgnoredProp(Sealed.differ) 756 | } 757 | 758 | test("Sealed trait: When only 'obtained' is provided when diffing") { 759 | assertConsoleDiffOutput( 760 | Differ[List[Sealed]], 761 | List(Sealed.Sub1(1)), 762 | List.empty[Sealed], 763 | s"""List( 764 | | ${R}Sub1( 765 | | i: 1 766 | | )$X 767 | |)""".stripMargin, 768 | ) 769 | } 770 | 771 | test("Sealed trait: When only 'expected' is provided when diffing") { 772 | assertConsoleDiffOutput( 773 | Differ[List[Sealed]], 774 | List.empty[Sealed], 775 | List(Sealed.Sub1(1)), 776 | s"""List( 777 | | ${G}Sub1( 778 | | i: 1 779 | | )$X 780 | |)""".stripMargin, 781 | ) 782 | } 783 | 784 | test("Sealed trait: Use subtype's custom Differ if present in scope when deriving") { 785 | assertConsoleDiffOutput( 786 | SealedWithCustom.differ, 787 | SealedWithCustom.Custom(1), 788 | SealedWithCustom.Custom(2), 789 | s"""Custom( 790 | | i: $grayIgnoredStr 791 | |)""".stripMargin, 792 | ) 793 | } 794 | 795 | test("Sealed trait: configure subtype differs by specifying the subtype name in the path") { 796 | val differ = Differ[Sealed] 797 | .configureRaw( 798 | ConfigurePath 799 | .of("Sub3", "list"), 800 | ConfigureOp.PairBy.ByFunc[CC, Int](_.i), 801 | ) 802 | .unsafeGet 803 | 804 | val diffResult = differ.diff( 805 | Sealed.Sub3( 806 | List( 807 | CC(1, "1", 1), 808 | CC(2, "2", 2), 809 | ), 810 | ), 811 | Sealed.Sub3( 812 | List( 813 | CC(2, "2", 2), 814 | CC(1, "1", 1), 815 | ), 816 | ), 817 | ) 818 | 819 | assert(diffResult.isOk) 820 | 821 | assertConsoleDiffOutput( 822 | differ, 823 | Sealed.Sub3( 824 | List( 825 | CC(1, "1", 1), 826 | CC(2, "2", 2), 827 | ), 828 | ), 829 | Sealed.Sub3( 830 | List( 831 | CC(2, "2", 2), 832 | CC(1, "2", 1), 833 | ), 834 | ), 835 | s"""Sub3( 836 | | list: List( 837 | | CC( 838 | | i: 1, 839 | | s: $R"1"$X -> $G"2"$X, 840 | | dd: 1.0 841 | | ), 842 | | CC( 843 | | i: 2, 844 | | s: "2", 845 | | dd: 2.0 846 | | ) 847 | | ) 848 | |)""".stripMargin, 849 | ) 850 | } 851 | 852 | test("Sealed trait: error if trying to configure with an invalid subtype name as path") { 853 | assertEquals( 854 | Differ[Sealed] 855 | .configureRaw( 856 | ConfigurePath 857 | .of("nope", "list"), 858 | ConfigureOp.PairBy.Index, 859 | ), 860 | Left( 861 | ConfigureError.UnrecognizedSubType( 862 | ConfigurePath(Vector("nope"), List("list")), 863 | Vector("Sub1", "Sub2", "Sub3", "Weird@Sub"), 864 | ), 865 | ), 866 | ) 867 | } 868 | 869 | test("Sealed trait: error if trying to update with an unsupported differ update op") { 870 | assertEquals( 871 | Differ[Sealed] 872 | .configureRaw( 873 | ConfigurePath.current, 874 | ConfigureOp.PairBy.Index, 875 | ), 876 | Left( 877 | ConfigureError.InvalidConfigureOp( 878 | ConfigurePath.current, 879 | ConfigureOp.PairBy.Index, 880 | "SealedTraitDiffer", 881 | ), 882 | ), 883 | ) 884 | } 885 | 886 | test("TransformedDiffer: isOk == true if two underlying values are equal") { 887 | assertOkIfValuesEqualProp(NewInt.differ) 888 | } 889 | 890 | test("TransformedDiffer: isOk == false if two underlying values are NOT equal") { 891 | assertNotOkIfNotEqualProp(NewInt.differ) 892 | } 893 | 894 | test("TransformedDiffer: isOk always true if differ is marked ignored") { 895 | assertIsOkIfIgnoredProp(NewInt.differ) 896 | } 897 | 898 | test("Differ.alwaysIgnore: Always returns ignored result") { 899 | assertEquals( 900 | AlwaysIgnoreClass.differ.diff(AlwaysIgnoreClass(1), AlwaysIgnoreClass(2)), 901 | DiffResult.ValueResult.Both( 902 | "[ALWAYS IGNORED]", 903 | "[ALWAYS IGNORED]", 904 | isSame = true, 905 | isIgnored = true, 906 | ), 907 | ) 908 | } 909 | 910 | test("Differ.alwaysIgnore: still return ignored result after unignore") { 911 | assertEquals( 912 | AlwaysIgnoreClass.differ.unignore.diff(AlwaysIgnoreClass(1), AlwaysIgnoreClass(2)): DiffResult, 913 | DiffResult.ValueResult.Both( 914 | "[ALWAYS IGNORED]", 915 | "[ALWAYS IGNORED]", 916 | isSame = true, 917 | isIgnored = true, 918 | ), 919 | ) 920 | } 921 | 922 | test("Differ.alwaysIgnore: configurePath returns PathTooLong error") { 923 | assertEquals( 924 | intercept[ConfigureError]( 925 | AlwaysIgnoreClass.differ.configure(_.i)(_.ignore), 926 | ), 927 | ConfigureError.PathTooLong(ConfigurePath(Vector("i"), Nil)), 928 | ) 929 | } 930 | 931 | test("Differ.alwaysIgnore: configurePairBy returns InvalidConfigureOp error") { 932 | assertEquals( 933 | AlwaysIgnoreClass.differ.configureRaw(ConfigurePath.current, ConfigureOp.PairBy.Index), 934 | Left(ConfigureError.InvalidConfigureOp(ConfigurePath.current, ConfigureOp.PairBy.Index, "AlwaysIgnoreDiffer")), 935 | ) 936 | } 937 | 938 | } 939 | --------------------------------------------------------------------------------