From 33e0a77f829d4037c115913e476e4261c06bb5da Mon Sep 17 00:00:00 2001 From: Goooler Date: Fri, 20 Mar 2026 09:20:12 +0800 Subject: [PATCH] Prettify the version diff reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` OLD: old.jar NEW: new.jar │ compressed │ uncompressed ├──────────┬─────────┬───────┼──────────┬──────────┬─────── JAR │ old │ new │ diff │ old │ new │ diff ───────┼──────────┼─────────┼───────┼──────────┼──────────┼─────── class │ 969 B │ 978 B │ +9 B │ 1012 B │ 1.01 KiB │ +22 B other │ 634 B │ 659 B │ +25 B │ 82 B │ 81 B │ -1 B ───────┼──────────┼─────────┼───────┼──────────┼──────────┼─────── total │ 1.57 KiB │ 1.6 KiB │ +34 B │ 1.07 KiB │ 1.09 KiB │ +21 B CLASSES │ old │ new │ diff ─────────┼─────┼─────┼─────────── classes │ 2 │ 2 │ 0 (+1 -1) methods │ 4 │ 4 │ 0 (+2 -2) fields │ 0 │ 0 │ 0 (+0 -0) ================= ==== JAR ==== ================= compressed │ uncompressed │ ──────────┬────────┼──────────┬────────┤ size │ diff │ size │ diff │ path ──────────┼────────┼──────────┼────────┼─────────────────────────────────────────────── 487 B │ +487 B │ 516 B │ +516 B │ + org/example/MainKt.class │ -484 B │ │ -506 B │ - org/example/Main3Kt.class 210 B │ +210 B │ 56 B │ +56 B │ + META-INF/org.example_untitled.kotlin_module │ -185 B │ │ -57 B │ - META-INF/untitled.kotlin_module 491 B │ +6 B │ 518 B │ +12 B │ ∆ org/example/Main2Kt.class ──────────┼────────┼──────────┼────────┼─────────────────────────────────────────────── 1.16 KiB │ +34 B │ 1.06 KiB │ +21 B │ (total) ===================== ==== CLASSES ==== ===================== CLASSES: old │ new │ diff ─────┼─────┼─────────── 2 │ 2 │ 0 (+1 -1) + org.example.MainKt - org.example.Main3Kt BYTECODE VERSIONS: version │ old │ new │ diff ─────────┼─────┼─────┼──────────── 65 │ 0 │ 2 │ +2 (+2 -0) 69 │ 2 │ 0 │ -2 (+0 -2) org.example.Main2Kt: 69 → 65 KOTLIN METADATA VERSIONS: version │ old │ new │ diff ─────────┼─────┼─────┼──────────── 2.3.0 │ 2 │ 0 │ -2 (+0 -2) 2.4.0 │ 0 │ 2 │ +2 (+2 -0) org.example.Main2Kt: 2.3.0 → 2.4.0 METHODS: old │ new │ diff ─────┼─────┼─────────── 4 │ 4 │ 0 (+2 -2) + org.example.MainKt main() + org.example.MainKt main(String[]) - org.example.Main3Kt main() - org.example.Main3Kt main(String[]) ``` --- .../jakewharton/diffuse/format/ClassTest.kt | 1 + .../diffuse/format/ResourceInput.kt | 5 - reports/build.gradle | 1 + .../com/jakewharton/diffuse/diff/JarsDiff.kt | 16 +- .../diffuse/diff/KotlinMetadataVersion.kt | 21 ++ .../jakewharton/diffuse/diff/VersionDiff.kt | 102 ++++++ .../diffuse/diff/BytecodeVersionDiffTest.kt | 316 ++++++++++++++++ .../diff/KotlinMetadataVersionDiffTest.kt | 344 ++++++++++++++++++ .../diffuse/diff/VersionDiffTest.kt | 101 +++++ .../diffuse/testing/requireResource.kt | 7 + 10 files changed, 903 insertions(+), 11 deletions(-) create mode 100644 reports/src/main/kotlin/com/jakewharton/diffuse/diff/KotlinMetadataVersion.kt create mode 100644 reports/src/main/kotlin/com/jakewharton/diffuse/diff/VersionDiff.kt create mode 100644 reports/src/test/kotlin/com/jakewharton/diffuse/diff/BytecodeVersionDiffTest.kt create mode 100644 reports/src/test/kotlin/com/jakewharton/diffuse/diff/KotlinMetadataVersionDiffTest.kt create mode 100644 reports/src/test/kotlin/com/jakewharton/diffuse/diff/VersionDiffTest.kt create mode 100644 test-helpers/src/main/kotlin/com/jakewharton/diffuse/testing/requireResource.kt diff --git a/formats/src/test/kotlin/com/jakewharton/diffuse/format/ClassTest.kt b/formats/src/test/kotlin/com/jakewharton/diffuse/format/ClassTest.kt index 35d0927c..4decc793 100644 --- a/formats/src/test/kotlin/com/jakewharton/diffuse/format/ClassTest.kt +++ b/formats/src/test/kotlin/com/jakewharton/diffuse/format/ClassTest.kt @@ -7,6 +7,7 @@ import assertk.assertions.hasSize import assertk.assertions.index import assertk.assertions.isEqualTo import com.jakewharton.diffuse.format.Class.Companion.toClass +import com.jakewharton.diffuse.testing.requireResource import java.util.function.Function import org.junit.Test diff --git a/formats/src/test/kotlin/com/jakewharton/diffuse/format/ResourceInput.kt b/formats/src/test/kotlin/com/jakewharton/diffuse/format/ResourceInput.kt index 581429a7..bc1b3a23 100644 --- a/formats/src/test/kotlin/com/jakewharton/diffuse/format/ResourceInput.kt +++ b/formats/src/test/kotlin/com/jakewharton/diffuse/format/ResourceInput.kt @@ -1,17 +1,12 @@ package com.jakewharton.diffuse.format import com.jakewharton.diffuse.io.Input -import java.lang.Class import java.net.URL import okio.buffer import okio.source fun URL.asInput(): Input = ResourceInput(this) -fun Class<*>.requireResource(name: String): URL { - return checkNotNull(getResource(name)) { "Resource $name not found." } -} - private class ResourceInput(private val url: URL) : Input { override val name get() = url.path.substringAfterLast('/') diff --git a/reports/build.gradle b/reports/build.gradle index 1e3aa938..8cab5047 100644 --- a/reports/build.gradle +++ b/reports/build.gradle @@ -9,4 +9,5 @@ dependencies { testImplementation libs.junit testImplementation libs.assertk + testImplementation projects.testHelpers } diff --git a/reports/src/main/kotlin/com/jakewharton/diffuse/diff/JarsDiff.kt b/reports/src/main/kotlin/com/jakewharton/diffuse/diff/JarsDiff.kt index 026a2b28..b1a8f4e2 100644 --- a/reports/src/main/kotlin/com/jakewharton/diffuse/diff/JarsDiff.kt +++ b/reports/src/main/kotlin/com/jakewharton/diffuse/diff/JarsDiff.kt @@ -19,12 +19,16 @@ internal class JarsDiff( ) { val classes = componentDiff(oldJars, newJars) { it.classes.map(Class::descriptor) } val bytecodeVersions = - componentDiff(oldJars, newJars) { jar -> - jar.classes.map { "${it.descriptor}: ${it.bytecodeVersion}" } + versionDiff(oldJars, newJars) { jar -> + jar.classes.associate { it.descriptor to it.bytecodeVersion } } val kotlinMetadataVersions = - componentDiff(oldJars, newJars) { jar -> - jar.classes.map { "${it.descriptor}: ${it.kotlinMetadataVersion.joinToString(".")}" } + versionDiff(oldJars, newJars) { jar -> + jar.classes + .filter { it.kotlinMetadataVersion.isNotEmpty() } + .associate { + it.descriptor to KotlinMetadataVersion(it.kotlinMetadataVersion.toList()) + } } val methods = componentDiff(oldJars, newJars) { it.members.filterIsInstance() } val declaredMethods = @@ -80,8 +84,8 @@ internal fun JarsDiff.toSummaryTable(name: String) = diffuseTable { internal fun JarsDiff.toDetailReport() = buildString { // TODO appendComponentDiff("STRINGS", strings)? appendComponentDiff("CLASSES", classes) - appendComponentDiff("BYTECODE VERSIONS", bytecodeVersions) - appendComponentDiff("KOTLIN METADATA VERSIONS", kotlinMetadataVersions) + appendVersionDiff("BYTECODE VERSIONS", bytecodeVersions) + appendVersionDiff("KOTLIN METADATA VERSIONS", kotlinMetadataVersions) appendComponentDiff("METHODS", methods) appendComponentDiff("FIELDS", fields) } diff --git a/reports/src/main/kotlin/com/jakewharton/diffuse/diff/KotlinMetadataVersion.kt b/reports/src/main/kotlin/com/jakewharton/diffuse/diff/KotlinMetadataVersion.kt new file mode 100644 index 00000000..d4ba2530 --- /dev/null +++ b/reports/src/main/kotlin/com/jakewharton/diffuse/diff/KotlinMetadataVersion.kt @@ -0,0 +1,21 @@ +package com.jakewharton.diffuse.diff + +internal data class KotlinMetadataVersion(private val numbers: List) : + Comparable { + private val major = numbers[0] + private val minor = numbers[1] + private val patch = numbers[2] + + override fun compareTo(other: KotlinMetadataVersion): Int = comparator.compare(this, other) + + override fun toString(): String = numbers.joinToString(".") + + private companion object { + val comparator = + compareBy( + KotlinMetadataVersion::major, + KotlinMetadataVersion::minor, + KotlinMetadataVersion::patch, + ) + } +} diff --git a/reports/src/main/kotlin/com/jakewharton/diffuse/diff/VersionDiff.kt b/reports/src/main/kotlin/com/jakewharton/diffuse/diff/VersionDiff.kt new file mode 100644 index 00000000..50c4c14c --- /dev/null +++ b/reports/src/main/kotlin/com/jakewharton/diffuse/diff/VersionDiff.kt @@ -0,0 +1,102 @@ +package com.jakewharton.diffuse.diff + +import com.jakewharton.diffuse.diffuseTable +import com.jakewharton.diffuse.format.TypeDescriptor +import com.jakewharton.diffuse.report.toDiffString +import com.jakewharton.picnic.TextAlignment.MiddleRight +import com.jakewharton.picnic.renderText +import kotlin.collections.iterator + +/** + * Diff for versioned items across two sets of containers. + * + * @param versionCounts per-version summary: maps each version to a pair of (`oldCount`, `newCount`) + * where the counts differ, sorted by `version`. + * @param changedClasses items that exist in both old and new but changed version, as triples of + * (`descriptor`, `oldVersion`, `newVersion`), sorted by `descriptor`. + */ +internal class VersionDiff>( + val versionCounts: Map>, + val changedClasses: List>, +) { + val changed = versionCounts.isNotEmpty() || changedClasses.isNotEmpty() +} + +internal fun > versionDiff( + oldItems: List, + newItems: List, + selector: (R) -> Map, +): VersionDiff { + val oldVersionMap = oldItems.flatMap { selector(it).entries }.associate { it.key to it.value } + val newVersionMap = newItems.flatMap { selector(it).entries }.associate { it.key to it.value } + + // Items present in both with different versions. + val changedClasses = + oldVersionMap + .mapNotNull { (descriptor, oldVersion) -> + val newVersion = newVersionMap[descriptor] + if (newVersion != null && newVersion != oldVersion) { + Triple(descriptor, oldVersion, newVersion) + } else { + null + } + } + .sortedBy { it.first } + + // Tally per-version counts across all items in old and new. + val allVersions = (oldVersionMap.values + newVersionMap.values).toSortedSet() + val versionCounts = + allVersions + .associateWith { version -> + val oldCount = oldVersionMap.values.count { it == version } + val newCount = newVersionMap.values.count { it == version } + oldCount to newCount + } + .filterValues { (oldCount, newCount) -> oldCount != newCount } + + return VersionDiff(versionCounts, changedClasses) +} + +internal fun StringBuilder.appendVersionDiff(name: String, diff: VersionDiff<*>) { + if (!diff.changed) return + appendLine() + appendLine("$name:") + appendLine() + + if (diff.versionCounts.isNotEmpty()) { + diffuseTable { + header { + row { + cell("version") + cell("old") + cell("new") + cell("diff") + } + } + + body { + cellStyle { alignment = MiddleRight } + + for ((version, counts) in diff.versionCounts) { + val (oldCount, newCount) = counts + val net = (newCount - oldCount).toDiffString() + val added = (newCount - oldCount).coerceAtLeast(0).toDiffString(zeroSign = '+') + val removed = (-(oldCount - newCount).coerceAtLeast(0)).toDiffString(zeroSign = '-') + row(version, oldCount, newCount, "$net ($added $removed)") + } + } + } + .renderText() + .prependIndent(" ") + .let(::appendLine) + } + + if (diff.changedClasses.isNotEmpty()) { + if (diff.versionCounts.isNotEmpty()) { + appendLine() + } + diff.changedClasses.forEach { (descriptor, oldVersion, newVersion) -> + appendLine(" $descriptor: $oldVersion → $newVersion") + } + } +} diff --git a/reports/src/test/kotlin/com/jakewharton/diffuse/diff/BytecodeVersionDiffTest.kt b/reports/src/test/kotlin/com/jakewharton/diffuse/diff/BytecodeVersionDiffTest.kt new file mode 100644 index 00000000..618acf3d --- /dev/null +++ b/reports/src/test/kotlin/com/jakewharton/diffuse/diff/BytecodeVersionDiffTest.kt @@ -0,0 +1,316 @@ +package com.jakewharton.diffuse.diff + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.jakewharton.diffuse.format.TypeDescriptor +import org.junit.Test + +class BytecodeVersionDiffTest { + @Test + fun nothingChangedProducesNoOutput() { + val diff = VersionDiff(versionCounts = emptyMap(), changedClasses = emptyList()) + assertThat(diff.changed).isFalse() + assertThat(buildString { appendVersionDiff("BYTECODE VERSIONS", diff) }).isEqualTo("") + } + + @Test + fun singleVersionUpgrade() { + // One class moved from version 65 to 69. + val diff = + VersionDiff( + versionCounts = + mapOf( + 65 to (1 to 0), // -1 net + 69 to (0 to 1), // +1 net + ), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lorg/example/MainKt;"), + 65, + 69, + ) // Up + ), + ) + + assertThat(diff.changed).isTrue() + assertThat(buildString { appendVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 65 │ 1 │ 0 │ -1 (+0 -1) + | 69 │ 0 │ 1 │ +1 (+1 -0) + | + | org.example.MainKt: 65 → 69 + |""" + .trimMargin() + ) + } + + @Test + fun multipleClassesUpgrade() { + // Two classes moved from 61 to 65; the unchanged-count version is filtered out. + val diff = + VersionDiff( + versionCounts = + mapOf( + 61 to (3 to 1), // -2 net + 65 to (0 to 2), // +2 net + ), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lcom/example/ClassA;"), + 61, + 65, + ), // Up + Triple( + TypeDescriptor("Lcom/example/ClassB;"), + 61, + 65, + ), // Up + ), + ) + + assertThat(buildString { appendVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 61 │ 3 │ 1 │ -2 (+0 -2) + | 65 │ 0 │ 2 │ +2 (+2 -0) + | + | com.example.ClassA: 61 → 65 + | com.example.ClassB: 61 → 65 + |""" + .trimMargin() + ) + } + + @Test + fun singleVersionDowngrade() { + // One class moved from version 69 back down to 65. + val diff = + VersionDiff( + versionCounts = + mapOf( + 65 to (0 to 1), // +1 net + 69 to (1 to 0), // -1 net + ), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lorg/example/MainKt;"), + 69, + 65, + ) // Down + ), + ) + + assertThat(diff.changed).isTrue() + assertThat(buildString { appendVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 65 │ 0 │ 1 │ +1 (+1 -0) + | 69 │ 1 │ 0 │ -1 (+0 -1) + | + | org.example.MainKt: 69 → 65 + |""" + .trimMargin() + ) + } + + @Test + fun multipleClassesDowngrade() { + // Two classes moved from 65 down to 61. + val diff = + VersionDiff( + versionCounts = + mapOf( + 61 to (0 to 2), // +2 net + 65 to (2 to 0), // -2 net + ), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lcom/example/ClassA;"), + 65, + 61, + ), // Down + Triple( + TypeDescriptor("Lcom/example/ClassB;"), + 65, + 61, + ), // Down + ), + ) + + assertThat(buildString { appendVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 61 │ 0 │ 2 │ +2 (+2 -0) + | 65 │ 2 │ 0 │ -2 (+0 -2) + | + | com.example.ClassA: 65 → 61 + | com.example.ClassB: 65 → 61 + |""" + .trimMargin() + ) + } + + @Test + fun mixedUpgradeAndDowngrade() { + val diff = + VersionDiff( + versionCounts = + mapOf( + 65 to (2 to 1), // -1 net + 69 to (0 to 1), // +1 net + // Version 61 is old 1, new 1 (Filtered) + ), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lcom/example/ClassA;"), + 61, + 65, + ), // Up + Triple( + TypeDescriptor("Lcom/example/ClassB;"), + 65, + 61, + ), // Down + Triple( + TypeDescriptor("Lcom/example/ClassC;"), + 65, + 69, + ), // Up + ), + ) + + assertThat(buildString { appendVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 65 │ 2 │ 1 │ -1 (+0 -1) + | 69 │ 0 │ 1 │ +1 (+1 -0) + | + | com.example.ClassA: 61 → 65 + | com.example.ClassB: 65 → 61 + | com.example.ClassC: 65 → 69 + |""" + .trimMargin() + ) + } + + @Test + fun noChangedClassesButVersionCountsDiffer() { + // New classes added at version 65, no pre-existing classes changed version. + val diff = + VersionDiff( + versionCounts = + mapOf( + 65 to (0 to 2) // +2 net + ), + changedClasses = emptyList(), + ) + + assertThat(buildString { appendVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 65 │ 0 │ 2 │ +2 (+2 -0) + |""" + .trimMargin() + ) + } + + @Test + fun onlyRemovals() { + // Classes removed at version 61. + val diff = + VersionDiff( + versionCounts = + mapOf( + 61 to (2 to 0) // -2 net + ), + changedClasses = emptyList(), + ) + + assertThat(buildString { appendVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 61 │ 2 │ 0 │ -2 (+0 -2) + |""" + .trimMargin() + ) + } + + @Test + fun netZeroVersionChangesStillProducesOutput() { + // Classes shifted versions but net counts per version stayed same. + // Table is hidden, but class list is shown. + val diff = + VersionDiff( + versionCounts = emptyMap(), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lcom/example/ClassA;"), + 61, + 65, + ), // Up + Triple( + TypeDescriptor("Lcom/example/ClassB;"), + 65, + 61, + ), // Down + ), + ) + + assertThat(diff.changed).isTrue() + assertThat(buildString { appendVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | com.example.ClassA: 61 → 65 + | com.example.ClassB: 65 → 61 + |""" + .trimMargin() + ) + } +} diff --git a/reports/src/test/kotlin/com/jakewharton/diffuse/diff/KotlinMetadataVersionDiffTest.kt b/reports/src/test/kotlin/com/jakewharton/diffuse/diff/KotlinMetadataVersionDiffTest.kt new file mode 100644 index 00000000..a5e14fb8 --- /dev/null +++ b/reports/src/test/kotlin/com/jakewharton/diffuse/diff/KotlinMetadataVersionDiffTest.kt @@ -0,0 +1,344 @@ +package com.jakewharton.diffuse.diff + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.jakewharton.diffuse.format.ApiMapping +import com.jakewharton.diffuse.format.Jar.Companion.toJar +import com.jakewharton.diffuse.format.TypeDescriptor +import com.jakewharton.diffuse.io.Input.Companion.asInput +import com.jakewharton.diffuse.testing.requireResource +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import okio.ByteString.Companion.toByteString +import org.junit.Test + +class KotlinMetadataVersionDiffTest { + @Test + fun nonKotlinClassesAreIgnored() { + val classBytes = Test::class.java.requireResource("Test.class").readBytes() + val jarBytes = ByteArrayOutputStream() + ZipOutputStream(jarBytes).use { zip -> + zip.putNextEntry(ZipEntry("org/junit/Test.class")) + zip.write(classBytes) + zip.closeEntry() + } + val inputJar = jarBytes.toByteArray().toByteString().asInput("input.jar").toJar() + + val diff = JarsDiff(listOf(inputJar), ApiMapping.EMPTY, listOf(inputJar), ApiMapping.EMPTY) + + assertThat(diff.kotlinMetadataVersions.versionCounts).isEqualTo(emptyMap()) + assertThat(diff.kotlinMetadataVersions.changedClasses).isEqualTo(emptyList()) + } + + @Test + fun nothingChangedProducesNoOutput() { + val diff = + VersionDiff(versionCounts = emptyMap(), changedClasses = emptyList()) + assertThat(diff.changed).isFalse() + assertThat(buildString { appendVersionDiff("KOTLIN METADATA VERSIONS", diff) }).isEqualTo("") + } + + @Test + fun singleVersionUpgrade() { + // One class moved from version 2.3.0 to 2.4.0. + val diff = + VersionDiff( + versionCounts = + mapOf( + KotlinMetadataVersion(2, 3, 0) to (1 to 0), // -1 net + KotlinMetadataVersion(2, 4, 0) to (0 to 1), // +1 net + ), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lorg/example/MainKt;"), + KotlinMetadataVersion(2, 3, 0), + KotlinMetadataVersion(2, 4, 0), + ) // Up + ), + ) + + assertThat(diff.changed).isTrue() + assertThat(buildString { appendVersionDiff("KOTLIN METADATA VERSIONS", diff) }) + .isEqualTo( + """ + | + |KOTLIN METADATA VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 2.3.0 │ 1 │ 0 │ -1 (+0 -1) + | 2.4.0 │ 0 │ 1 │ +1 (+1 -0) + | + | org.example.MainKt: 2.3.0 → 2.4.0 + |""" + .trimMargin() + ) + } + + @Test + fun multipleClassesUpgrade() { + // Two classes moved from 1.9.0 to 2.0.0; the unchanged-count version is filtered out. + val diff = + VersionDiff( + versionCounts = + mapOf( + KotlinMetadataVersion(1, 9, 0) to (3 to 1), // -2 net + KotlinMetadataVersion(2, 0, 0) to (0 to 2), // +2 net + ), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lcom/example/ClassA;"), + KotlinMetadataVersion(1, 9, 0), + KotlinMetadataVersion(2, 0, 0), + ), // Up + Triple( + TypeDescriptor("Lcom/example/ClassB;"), + KotlinMetadataVersion(1, 9, 0), + KotlinMetadataVersion(2, 0, 0), + ), // Up + ), + ) + + assertThat(buildString { appendVersionDiff("KOTLIN METADATA VERSIONS", diff) }) + .isEqualTo( + """ + | + |KOTLIN METADATA VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 1.9.0 │ 3 │ 1 │ -2 (+0 -2) + | 2.0.0 │ 0 │ 2 │ +2 (+2 -0) + | + | com.example.ClassA: 1.9.0 → 2.0.0 + | com.example.ClassB: 1.9.0 → 2.0.0 + |""" + .trimMargin() + ) + } + + @Test + fun singleVersionDowngrade() { + // One class moved from version 2.4.0 back down to 2.3.0. + val diff = + VersionDiff( + versionCounts = + mapOf( + KotlinMetadataVersion(2, 3, 0) to (0 to 1), // +1 net + KotlinMetadataVersion(2, 4, 0) to (1 to 0), // -1 net + ), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lorg/example/MainKt;"), + KotlinMetadataVersion(2, 4, 0), + KotlinMetadataVersion(2, 3, 0), + ) // Down + ), + ) + + assertThat(diff.changed).isTrue() + assertThat(buildString { appendVersionDiff("KOTLIN METADATA VERSIONS", diff) }) + .isEqualTo( + """ + | + |KOTLIN METADATA VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 2.3.0 │ 0 │ 1 │ +1 (+1 -0) + | 2.4.0 │ 1 │ 0 │ -1 (+0 -1) + | + | org.example.MainKt: 2.4.0 → 2.3.0 + |""" + .trimMargin() + ) + } + + @Test + fun multipleClassesDowngrade() { + // Two classes moved from 2.0.0 down to 1.9.0. + val diff = + VersionDiff( + versionCounts = + mapOf( + KotlinMetadataVersion(1, 9, 0) to (0 to 2), // +2 net + KotlinMetadataVersion(2, 0, 0) to (2 to 0), // -2 net + ), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lcom/example/ClassA;"), + KotlinMetadataVersion(2, 0, 0), + KotlinMetadataVersion(1, 9, 0), + ), // Down + Triple( + TypeDescriptor("Lcom/example/ClassB;"), + KotlinMetadataVersion(2, 0, 0), + KotlinMetadataVersion(1, 9, 0), + ), // Down + ), + ) + + assertThat(buildString { appendVersionDiff("KOTLIN METADATA VERSIONS", diff) }) + .isEqualTo( + """ + | + |KOTLIN METADATA VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 1.9.0 │ 0 │ 2 │ +2 (+2 -0) + | 2.0.0 │ 2 │ 0 │ -2 (+0 -2) + | + | com.example.ClassA: 2.0.0 → 1.9.0 + | com.example.ClassB: 2.0.0 → 1.9.0 + |""" + .trimMargin() + ) + } + + @Test + fun mixedUpgradeAndDowngrade() { + val diff = + VersionDiff( + versionCounts = + mapOf( + KotlinMetadataVersion(2, 0, 0) to (2 to 1), // -1 net + KotlinMetadataVersion(2, 4, 0) to (0 to 1), // +1 net + // Version 1.9.0 is old 1, new 1 (Filtered) + ), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lcom/example/ClassA;"), + KotlinMetadataVersion(1, 9, 0), + KotlinMetadataVersion(2, 0, 0), + ), // Up + Triple( + TypeDescriptor("Lcom/example/ClassB;"), + KotlinMetadataVersion(2, 0, 0), + KotlinMetadataVersion(1, 9, 0), + ), // Down + Triple( + TypeDescriptor("Lcom/example/ClassC;"), + KotlinMetadataVersion(2, 0, 0), + KotlinMetadataVersion(2, 4, 0), + ), // Up + ), + ) + + assertThat(buildString { appendVersionDiff("KOTLIN METADATA VERSIONS", diff) }) + .isEqualTo( + """ + | + |KOTLIN METADATA VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 2.0.0 │ 2 │ 1 │ -1 (+0 -1) + | 2.4.0 │ 0 │ 1 │ +1 (+1 -0) + | + | com.example.ClassA: 1.9.0 → 2.0.0 + | com.example.ClassB: 2.0.0 → 1.9.0 + | com.example.ClassC: 2.0.0 → 2.4.0 + |""" + .trimMargin() + ) + } + + @Test + fun noChangedClassesButVersionCountsDiffer() { + // New classes added at version 2.0.0, no pre-existing classes changed version. + val diff = + VersionDiff( + versionCounts = + mapOf( + KotlinMetadataVersion(2, 0, 0) to (0 to 2) // +2 net + ), + changedClasses = emptyList(), + ) + + assertThat(buildString { appendVersionDiff("KOTLIN METADATA VERSIONS", diff) }) + .isEqualTo( + """ + | + |KOTLIN METADATA VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 2.0.0 │ 0 │ 2 │ +2 (+2 -0) + |""" + .trimMargin() + ) + } + + @Test + fun onlyRemovals() { + // Classes removed at version 1.9.0. + val diff = + VersionDiff( + versionCounts = + mapOf( + KotlinMetadataVersion(1, 9, 0) to (2 to 0) // -2 net + ), + changedClasses = emptyList(), + ) + + assertThat(buildString { appendVersionDiff("KOTLIN METADATA VERSIONS", diff) }) + .isEqualTo( + """ + | + |KOTLIN METADATA VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 1.9.0 │ 2 │ 0 │ -2 (+0 -2) + |""" + .trimMargin() + ) + } + + @Test + fun netZeroVersionChangesStillProducesOutput() { + // Classes shifted versions but net counts per version stayed same. + // Table is hidden, but class list is shown. + val diff = + VersionDiff( + versionCounts = emptyMap(), + changedClasses = + listOf( + Triple( + TypeDescriptor("Lcom/example/ClassA;"), + KotlinMetadataVersion(1, 9, 0), + KotlinMetadataVersion(2, 0, 0), + ), // Up + Triple( + TypeDescriptor("Lcom/example/ClassB;"), + KotlinMetadataVersion(2, 0, 0), + KotlinMetadataVersion(1, 9, 0), + ), // Down + ), + ) + + assertThat(diff.changed).isTrue() + assertThat(buildString { appendVersionDiff("KOTLIN METADATA VERSIONS", diff) }) + .isEqualTo( + """ + | + |KOTLIN METADATA VERSIONS: + | + | com.example.ClassA: 1.9.0 → 2.0.0 + | com.example.ClassB: 2.0.0 → 1.9.0 + |""" + .trimMargin() + ) + } +} + +private fun KotlinMetadataVersion(vararg numbers: Int) = KotlinMetadataVersion(numbers.toList()) diff --git a/reports/src/test/kotlin/com/jakewharton/diffuse/diff/VersionDiffTest.kt b/reports/src/test/kotlin/com/jakewharton/diffuse/diff/VersionDiffTest.kt new file mode 100644 index 00000000..2f45764c --- /dev/null +++ b/reports/src/test/kotlin/com/jakewharton/diffuse/diff/VersionDiffTest.kt @@ -0,0 +1,101 @@ +package com.jakewharton.diffuse.diff + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.jakewharton.diffuse.format.TypeDescriptor +import org.junit.Test + +class VersionDiffTest { + @Test + fun nothingChanged() { + val items = + listOf( + mapOf( + TypeDescriptor("Lcom/example/ClassA;") to 61, + TypeDescriptor("Lcom/example/ClassB;") to 65, + ) + ) + + val diff = versionDiff(items, items) { it } + + assertThat(diff.changed).isFalse() + assertThat(diff.versionCounts).isEmpty() + assertThat(diff.changedClasses).isEmpty() + } + + @Test + fun unchangedVersionsAreFilteredOutFromVersionCounts() { + val oldItems = + listOf( + mapOf( + TypeDescriptor("Lcom/example/ClassA;") to 61, + TypeDescriptor("Lcom/example/ClassB;") to 65, + TypeDescriptor("Lcom/example/ClassUnchanged;") to 61, + ) + ) + val newItems = + listOf( + mapOf( + TypeDescriptor("Lcom/example/ClassA;") to 65, + TypeDescriptor("Lcom/example/ClassB;") to 61, + TypeDescriptor("Lcom/example/ClassUnchanged;") to 61, + ) + ) + + val diff = versionDiff(oldItems, newItems) { it } + + assertThat(diff.changed).isTrue() + // 61: old=2, new=2 (net=0 -> filtered) + // 65: old=1, new=1 (net=0 -> filtered) + assertThat(diff.versionCounts).isEmpty() + assertThat(diff.changedClasses) + .containsExactly( + Triple(TypeDescriptor("Lcom/example/ClassA;"), 61, 65), + Triple(TypeDescriptor("Lcom/example/ClassB;"), 65, 61), + ) + } + + @Test + fun partialVersionCountChangeFiltersOnlyUnchanged() { + val oldItems = + listOf( + mapOf( + TypeDescriptor("Lcom/example/ClassA;") to 61, + TypeDescriptor("Lcom/example/ClassB;") to 65, + TypeDescriptor("Lcom/example/ClassC;") to 65, + ) + ) + val newItems = + listOf( + mapOf( + TypeDescriptor("Lcom/example/ClassA;") to 65, + TypeDescriptor("Lcom/example/ClassB;") to 61, + TypeDescriptor("Lcom/example/ClassC;") to 69, + ) + ) + + val diff = versionDiff(oldItems, newItems) { it } + + assertThat(diff.changed).isTrue() + // 61: old=1, new=1 (net=0 -> filtered) + // 65: old=2, new=1 (net=-1 -> kept) + // 69: old=0, new=1 (net=+1 -> kept) + assertThat(diff.versionCounts) + .isEqualTo( + mapOf( + 65 to (2 to 1), + 69 to (0 to 1), + ) + ) + assertThat(diff.changedClasses) + .containsExactly( + Triple(TypeDescriptor("Lcom/example/ClassA;"), 61, 65), + Triple(TypeDescriptor("Lcom/example/ClassB;"), 65, 61), + Triple(TypeDescriptor("Lcom/example/ClassC;"), 65, 69), + ) + } +} diff --git a/test-helpers/src/main/kotlin/com/jakewharton/diffuse/testing/requireResource.kt b/test-helpers/src/main/kotlin/com/jakewharton/diffuse/testing/requireResource.kt new file mode 100644 index 00000000..1efd7677 --- /dev/null +++ b/test-helpers/src/main/kotlin/com/jakewharton/diffuse/testing/requireResource.kt @@ -0,0 +1,7 @@ +package com.jakewharton.diffuse.testing + +import java.net.URL + +fun Class<*>.requireResource(name: String): URL { + return checkNotNull(getResource(name)) { "Resource $name not found." } +}