From a947f44377e13e124eafb2d1c405bd259321f2bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Mon, 22 Jun 2026 11:58:08 +0200 Subject: [PATCH 1/5] feat(core): add `Outcome` result type for domain-specific success/failure handling --- README.md | 6 + components/bom/build.gradle.kts | 3 +- components/core/README.md | 10 + components/core/outcome/CHANGELOG.md | 4 + components/core/outcome/README.md | 62 ++++++ components/core/outcome/api/jvm/outcome.api | 0 components/core/outcome/api/outcome.klib.api | 56 +++++ components/core/outcome/build.gradle.kts | 21 ++ .../components/core/outcome/Outcome.kt | 157 ++++++++++++++ .../components/core/outcome/OutcomeTest.kt | 199 ++++++++++++++++++ components/core/version.properties | 3 + settings.gradle.kts | 13 +- 12 files changed, 528 insertions(+), 6 deletions(-) create mode 100644 components/core/README.md create mode 100644 components/core/outcome/CHANGELOG.md create mode 100644 components/core/outcome/README.md create mode 100644 components/core/outcome/api/jvm/outcome.api create mode 100644 components/core/outcome/api/outcome.klib.api create mode 100644 components/core/outcome/build.gradle.kts create mode 100644 components/core/outcome/src/commonMain/kotlin/net/thunderbird/components/core/outcome/Outcome.kt create mode 100644 components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt create mode 100644 components/core/version.properties diff --git a/README.md b/README.md index be33d6e..2c2a236 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ The components are in different stages of development, and some may not be fully - 🧪 Testing: The component is being tested and may have known issues. - ✅ Stable: The component is stable and ready for production use. +### [Core Components](components/core/README.md) + +| Component | Status | Module | Maven Coordinate | Description | +|----------------------------------------------|-------------------|----------------------------|-------------------------------------------|-------------------------------------------------------------------------| +| [Outcome](components/core/outcome/README.md) | 🛠 In development | `:components:core:outcome` | `net.thunderbird.components.core:outcome` | Small result type with a flexible failure type, unlike Kotlin `Result`. | + ## Contributing We welcome contributions to this repository! If you would like to contribute, please read our diff --git a/components/bom/build.gradle.kts b/components/bom/build.gradle.kts index 224b1eb..16d3101 100644 --- a/components/bom/build.gradle.kts +++ b/components/bom/build.gradle.kts @@ -4,7 +4,6 @@ plugins { dependencies { constraints { - // Add constraints for published components here, e.g.: - // api("net.thunderbird:some-component:${project.version}") + api(projects.components.core.outcome) } } diff --git a/components/core/README.md b/components/core/README.md new file mode 100644 index 0000000..650fd3b --- /dev/null +++ b/components/core/README.md @@ -0,0 +1,10 @@ +# Thunderbird Core Components + +Core components provide small, foundational building blocks that can be reused by higher-level +components and applications. + +## Components + +| Component | Module | Maven Coordinate | Description | +|-----------|----------------------|-------------------------------------------|----------------------------------------------------------------------------| +| [Outcome](outcome/README.md) | `:components:core:outcome` | `net.thunderbird.components.core:outcome` | Small result type with a flexible failure type, unlike Kotlin `Result`. | diff --git a/components/core/outcome/CHANGELOG.md b/components/core/outcome/CHANGELOG.md new file mode 100644 index 0000000..617d979 --- /dev/null +++ b/components/core/outcome/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## Unreleased + diff --git a/components/core/outcome/README.md b/components/core/outcome/README.md new file mode 100644 index 0000000..12b57a0 --- /dev/null +++ b/components/core/outcome/README.md @@ -0,0 +1,62 @@ +# Thunderbird Outcome Component + +Outcome provides a small result type for operations that need a domain-specific failure type. + +Use it when Kotlin `Result` is too restrictive because callers need a typed failure value instead +of only a `Throwable`. + +## Dependency + +```kotlin +dependencies { + implementation("net.thunderbird.components.core:outcome:") +} +``` + +## Usage + +```kotlin +import net.thunderbird.components.core.outcome.Outcome +import net.thunderbird.components.core.outcome.flatMapSuccess +import net.thunderbird.components.core.outcome.fold +import net.thunderbird.components.core.outcome.mapFailure +import net.thunderbird.components.core.outcome.mapSuccess + +sealed interface ReadError { + data object Missing : ReadError + data object Invalid : ReadError +} + +fun readValue(): Outcome { + return Outcome.success("42") +} + +val result = readValue() + .mapSuccess { it.toInt() } + .mapFailure { error, _ -> error } + .flatMapSuccess { value -> + if (value > 0) { + Outcome.success(value) + } else { + Outcome.failure(ReadError.Invalid) + } + } + .fold( + onSuccess = { value -> "Read value: $value" }, + onFailure = { error -> "Could not read value: $error" }, + ) +``` + +## Error Handling + +Prefer domain-specific failure types over plain strings or exceptions: + +```kotlin +sealed interface FileReadError { + data object NotFound : FileReadError + data object PermissionDenied : FileReadError + data class Unknown(val message: String) : FileReadError +} +``` + +This keeps callers exhaustive and makes expected failure states visible at the API boundary. diff --git a/components/core/outcome/api/jvm/outcome.api b/components/core/outcome/api/jvm/outcome.api new file mode 100644 index 0000000..e69de29 diff --git a/components/core/outcome/api/outcome.klib.api b/components/core/outcome/api/outcome.klib.api new file mode 100644 index 0000000..9f6fee4 --- /dev/null +++ b/components/core/outcome/api/outcome.klib.api @@ -0,0 +1,56 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +sealed interface <#A: out kotlin/Any?, #B: out kotlin/Any?> net.thunderbird.components.core.outcome/Outcome { // net.thunderbird.components.core.outcome/Outcome|null[0] + open val isFailure // net.thunderbird.components.core.outcome/Outcome.isFailure|{}isFailure[0] + open fun (): kotlin/Boolean // net.thunderbird.components.core.outcome/Outcome.isFailure.|(){}[0] + open val isSuccess // net.thunderbird.components.core.outcome/Outcome.isSuccess|{}isSuccess[0] + open fun (): kotlin/Boolean // net.thunderbird.components.core.outcome/Outcome.isSuccess.|(){}[0] + + final class <#A1: out kotlin/Any?> Failure : net.thunderbird.components.core.outcome/Outcome { // net.thunderbird.components.core.outcome/Outcome.Failure|null[0] + constructor (#A1, kotlin/Any? = ...) // net.thunderbird.components.core.outcome/Outcome.Failure.|(1:0;kotlin.Any?){}[0] + + final val cause // net.thunderbird.components.core.outcome/Outcome.Failure.cause|{}cause[0] + final fun (): kotlin/Any? // net.thunderbird.components.core.outcome/Outcome.Failure.cause.|(){}[0] + final val error // net.thunderbird.components.core.outcome/Outcome.Failure.error|{}error[0] + final fun (): #A1 // net.thunderbird.components.core.outcome/Outcome.Failure.error.|(){}[0] + + final fun component1(): #A1 // net.thunderbird.components.core.outcome/Outcome.Failure.component1|component1(){}[0] + final fun component2(): kotlin/Any? // net.thunderbird.components.core.outcome/Outcome.Failure.component2|component2(){}[0] + final fun copy(#A1 = ..., kotlin/Any? = ...): net.thunderbird.components.core.outcome/Outcome.Failure<#A1> // net.thunderbird.components.core.outcome/Outcome.Failure.copy|copy(1:0;kotlin.Any?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // net.thunderbird.components.core.outcome/Outcome.Failure.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // net.thunderbird.components.core.outcome/Outcome.Failure.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // net.thunderbird.components.core.outcome/Outcome.Failure.toString|toString(){}[0] + } + + final class <#A1: out kotlin/Any?> Success : net.thunderbird.components.core.outcome/Outcome<#A1, kotlin/Nothing> { // net.thunderbird.components.core.outcome/Outcome.Success|null[0] + constructor (#A1) // net.thunderbird.components.core.outcome/Outcome.Success.|(1:0){}[0] + + final val data // net.thunderbird.components.core.outcome/Outcome.Success.data|{}data[0] + final fun (): #A1 // net.thunderbird.components.core.outcome/Outcome.Success.data.|(){}[0] + + final fun component1(): #A1 // net.thunderbird.components.core.outcome/Outcome.Success.component1|component1(){}[0] + final fun copy(#A1 = ...): net.thunderbird.components.core.outcome/Outcome.Success<#A1> // net.thunderbird.components.core.outcome/Outcome.Success.copy|copy(1:0){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // net.thunderbird.components.core.outcome/Outcome.Success.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // net.thunderbird.components.core.outcome/Outcome.Success.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // net.thunderbird.components.core.outcome/Outcome.Success.toString|toString(){}[0] + } + + final object Companion { // net.thunderbird.components.core.outcome/Outcome.Companion|null[0] + final fun <#A2: kotlin/Any?> failure(#A2): net.thunderbird.components.core.outcome/Outcome // net.thunderbird.components.core.outcome/Outcome.Companion.failure|failure(0:0){0§}[0] + final fun <#A2: kotlin/Any?> success(#A2): net.thunderbird.components.core.outcome/Outcome<#A2, kotlin/Nothing> // net.thunderbird.components.core.outcome/Outcome.Companion.success|success(0:0){0§}[0] + } +} + +final fun <#A: kotlin/Any?, #B: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/handle(kotlin/Function1<#A, kotlin/Unit>, kotlin/Function1<#B, kotlin/Unit>) // net.thunderbird.components.core.outcome/handle|handle@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.Function1<0:0,kotlin.Unit>;kotlin.Function1<0:1,kotlin.Unit>){0§;1§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?, #C: kotlin/Any?, #D: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/map(kotlin/Function1<#A, #C>, kotlin/Function2<#B, kotlin/Any?, #D>): net.thunderbird.components.core.outcome/Outcome<#C, #D> // net.thunderbird.components.core.outcome/map|map@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.Function1<0:0,0:2>;kotlin.Function2<0:1,kotlin.Any?,0:3>){0§;1§;2§;3§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?, #C: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/flatMapSuccess(kotlin/Function1<#A, net.thunderbird.components.core.outcome/Outcome<#C, #B>>): net.thunderbird.components.core.outcome/Outcome<#C, #B> // net.thunderbird.components.core.outcome/flatMapSuccess|flatMapSuccess@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.Function1<0:0,net.thunderbird.components.core.outcome.Outcome<0:2,0:1>>){0§;1§;2§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?, #C: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/fold(kotlin/Function1<#A, #C>, kotlin/Function1<#B, #C>): #C // net.thunderbird.components.core.outcome/fold|fold@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.Function1<0:0,0:2>;kotlin.Function1<0:1,0:2>){0§;1§;2§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?, #C: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/mapFailure(kotlin/Function2<#B, kotlin/Any?, #C>): net.thunderbird.components.core.outcome/Outcome<#A, #C> // net.thunderbird.components.core.outcome/mapFailure|mapFailure@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.Function2<0:1,kotlin.Any?,0:2>){0§;1§;2§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?, #C: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #C>).net.thunderbird.components.core.outcome/mapSuccess(kotlin/Function1<#A, #B>): net.thunderbird.components.core.outcome/Outcome<#B, #C> // net.thunderbird.components.core.outcome/mapSuccess|mapSuccess@net.thunderbird.components.core.outcome.Outcome<0:0,0:2>(kotlin.Function1<0:0,0:1>){0§;1§;2§}[0] +final suspend fun <#A: kotlin/Any?, #B: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/handleAsync(kotlin.coroutines/SuspendFunction1<#A, kotlin/Unit>, kotlin.coroutines/SuspendFunction1<#B, kotlin/Unit>) // net.thunderbird.components.core.outcome/handleAsync|handleAsync@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.coroutines.SuspendFunction1<0:0,kotlin.Unit>;kotlin.coroutines.SuspendFunction1<0:1,kotlin.Unit>){0§;1§}[0] diff --git a/components/core/outcome/build.gradle.kts b/components/core/outcome/build.gradle.kts new file mode 100644 index 0000000..42b2184 --- /dev/null +++ b/components/core/outcome/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.tb.library.kmp) +} + +kotlin { + @Suppress("UnstableApiUsage") + android { + namespace = "net.thunderbird.components.core.outcome" + } + sourceSets { + commonTest.dependencies { + implementation(libs.assertk) + implementation(libs.kotlinx.coroutines.test) + } + } +} + +codeCoverage { + branchCoverage = 28 + lineCoverage = 53 +} diff --git a/components/core/outcome/src/commonMain/kotlin/net/thunderbird/components/core/outcome/Outcome.kt b/components/core/outcome/src/commonMain/kotlin/net/thunderbird/components/core/outcome/Outcome.kt new file mode 100644 index 0000000..371cbe0 --- /dev/null +++ b/components/core/outcome/src/commonMain/kotlin/net/thunderbird/components/core/outcome/Outcome.kt @@ -0,0 +1,157 @@ +package net.thunderbird.components.core.outcome + +/** + * A sealed interface representing the outcome of an operation. + * + * @param SUCCESS The type of the value when the operation succeeds. + * @param FAILURE The type of the error when the operation fails. + */ +public sealed interface Outcome { + + /** + * A successful outcome with a value of type [SUCCESS]. + * + * @param data The value of the successful outcome. + */ + public data class Success(public val data: SUCCESS) : Outcome + + /** + * A failed outcome with an error of type [FAILURE]. + * + * @param error The error of the failed outcome. + * @param cause The cause of the failed outcome. + */ + public data class Failure( + public val error: FAILURE, + public val cause: Any? = null, + ) : Outcome + + /** + * Whether the outcome is a success. + */ + public val isSuccess: Boolean + get() = this is Success + + /** + * Whether the outcome is a failure. + */ + public val isFailure: Boolean + get() = this is Failure + + public companion object { + /** + * Create a [Outcome.Success] outcome with the given value. + * + * @param data The value of the successful outcome. + */ + public fun success(data: SUCCESS): Outcome = Success(data) + + /** + * Create a [Outcome.Failure] outcome with the given error. + * + * @param error The error of the failed outcome. + */ + public fun failure(error: FAILURE): Outcome = Failure(error) + } +} + +/** + * Map the value and error of an [Outcome] to a new value. + * + * @param transformSuccess The function to transform the value of a [Outcome.Success] to a new value. + * @param transformFailure The function to transform the value of a [Outcome.Failure] to a new value. + */ +public inline fun Outcome.map( + transformSuccess: (IN_SUCCESS) -> OUT_SUCCESS, + transformFailure: (IN_FAILURE, Any?) -> OUT_FAILURE, +): Outcome { + return when (this) { + is Outcome.Success -> Outcome.Success(transformSuccess(data)) + is Outcome.Failure -> Outcome.Failure(transformFailure(error, cause)) + } +} + +/** + * Map the value of a [Outcome] to a new value. + * + * @param transformSuccess The function to transform the value of a [Outcome.Success] to a new value. + */ +public inline fun Outcome.mapSuccess( + transformSuccess: (IN_SUCCESS) -> OUT_SUCCESS, +): Outcome { + return when (this) { + is Outcome.Success -> Outcome.Success(transformSuccess(data)) + is Outcome.Failure -> this + } +} + +/** + * Flat map the value and error of an [Outcome] to a new [Outcome]. + */ +public inline fun Outcome.flatMapSuccess( + transformSuccess: (IN_SUCCESS) -> Outcome, +): Outcome { + return when (this) { + is Outcome.Success -> transformSuccess(data) + is Outcome.Failure -> this + } +} + +/** + * Map the error of a [Outcome] to a new value. + * + * @param transformFailure The function to transform the value of a [Outcome.Failure] to a new value. + */ +public inline fun Outcome.mapFailure( + transformFailure: (IN_FAILURE, Any?) -> OUT_FAILURE, +): Outcome { + return when (this) { + is Outcome.Success -> this + is Outcome.Failure -> Outcome.Failure(transformFailure(error, cause)) + } +} + +/** + * Handle the value of an [Outcome] and execute the given function. + * + * @param onSuccess The function to execute if the outcome is a [Outcome.Success]. + * @param onFailure The function to execute if the outcome is a [Outcome.Failure]. + */ +public fun Outcome.handle( + onSuccess: (SUCCESS) -> Unit, + onFailure: (FAILURE) -> Unit, +) { + when (this) { + is Outcome.Success -> onSuccess(data) + is Outcome.Failure -> onFailure(error) + } +} + +/** + * Handle the value of an [Outcome] and execute the given function. + * + * @param onSuccess The function to execute if the outcome is a [Outcome.Success]. + * @param onFailure The function to execute if the outcome is a [Outcome.Failure]. + */ +public suspend fun Outcome.handleAsync( + onSuccess: suspend (SUCCESS) -> Unit, + onFailure: suspend (FAILURE) -> Unit, +) { + when (this) { + is Outcome.Success -> onSuccess(data) + is Outcome.Failure -> onFailure(error) + } +} + +/** + * Fold the value of an [Outcome] to a new value. + */ +public inline fun Outcome.fold( + onSuccess: (SUCCESS) -> R, + onFailure: (FAILURE) -> R, +): R { + return when (this) { + is Outcome.Success -> onSuccess(data) + is Outcome.Failure -> onFailure(error) + } +} diff --git a/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt b/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt new file mode 100644 index 0000000..1cbc7bf --- /dev/null +++ b/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt @@ -0,0 +1,199 @@ +package net.thunderbird.components.core.outcome + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +class OutcomeTest { + + @Test + fun `given Success when checking then has correct flags and data`() { + // Arrange + val outcome: Outcome = Outcome.success(42) + + // Act + Assert + assertThat(outcome.isSuccess).isTrue() + assertThat(outcome.isFailure).isFalse() + val data = (outcome as Outcome.Success).data + assertThat(data).isEqualTo(42) + } + + @Test + fun `given Failure when checking then has correct flags and error`() { + // Arrange + val outcome: Outcome = Outcome.failure("error") + + // Act + Assert + assertThat(outcome.isFailure).isTrue() + assertThat(outcome.isSuccess).isFalse() + val error = (outcome as Outcome.Failure).error + assertThat(error).isEqualTo("error") + } + + @Test + fun `given Success when map is called then transforms success value`() { + // Arrange + val outcome: Outcome = Outcome.Success(7) + + // Act + val mapped = outcome.map( + transformSuccess = { it * 2 }, + transformFailure = { err, _ -> "$err!" }, + ) + + // Assert + val data = (mapped as Outcome.Success).data + assertThat(data).isEqualTo(14) + } + + @Test + fun `given Failure with cause when map is called then transforms failure value and provides cause`() { + // Arrange + val cause = IllegalStateException("cause") + val outcome: Outcome = Outcome.Failure("error", cause) + + // Act + val mapped = outcome.map( + transformSuccess = { it * 2 }, + transformFailure = { err, receivedCause -> + assertThat(receivedCause).isEqualTo(cause) + "$err-transformed" + }, + ) + + // Assert + val failure = (mapped as Outcome.Failure) + assertThat(failure.error).isEqualTo("error-transformed") + } + + @Test + fun `given Success and Failure when mapSuccess is called then only success is transformed`() { + // Arrange & Act + val success = Outcome.Success(3).mapSuccess { it + 1 } + val failure = Outcome.Failure("failure").mapSuccess { 999 } + + // Assert + assertThat((success as Outcome.Success).data).isEqualTo(4) + // Failure must be unchanged + assertThat((failure as Outcome.Failure).error).isEqualTo("failure") + } + + @Test + fun `given Success and Failure when flatMapSuccess is called then success is flat-mapped and failure passes through`() { + // Arrange & Act + val onSuccess = Outcome.Success(10).flatMapSuccess { value -> + if (value > 5) Outcome.Success("success") else Outcome.Failure("failure") + } + val onFailure: Outcome = + Outcome.Failure("failure").flatMapSuccess { Outcome.Success("won't happen") } + + // Assert + assertThat((onSuccess as Outcome.Success).data).isEqualTo("success") + assertThat((onFailure as Outcome.Failure).error).isEqualTo("failure") + } + + @Test + fun `given Success and Failure when mapFailure is called then only failure is transformed and cause provided`() { + // Arrange & Act + val cause = RuntimeException("cause") + val success = Outcome.Success("success").mapFailure { e: String, _ -> "$e?" } + val failure = Outcome.Failure("fail", cause).mapFailure { e, c -> + assertThat(c).isEqualTo(cause) + 999 + } + + // Assert + assertThat((success as Outcome.Success).data).isEqualTo("success") + assertThat((failure as Outcome.Failure).error).isEqualTo(999) + } + + @Test + fun `given Outcome when handle is invoked then calls only the matching callback`() { + // Arrange + var successCalledWith: Int? = null + var failureCalledWith: String? = null + + // Act + Outcome.Success(5).handle( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + // Assert + assertThat(successCalledWith).isEqualTo(5) + assertThat(failureCalledWith).isNull() + + // Arrange + successCalledWith = null + val failureOutcome: Outcome = Outcome.Failure("failure") + // Act + failureOutcome.handle( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + // Assert + assertThat(successCalledWith).isNull() + assertThat(failureCalledWith).isEqualTo("failure") + } + + @Test + fun `given Outcome when handleAsync is invoked then calls only the matching suspending callback`() = runTest { + // Arrange + var successCalledWith: Int? = null + var failureCalledWith: String? = null + + // Act + Outcome.Success(1).handleAsync( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + // Assert + assertThat(successCalledWith).isEqualTo(1) + assertThat(failureCalledWith).isNull() + + // Arrange + successCalledWith = null + val failureOutcome: Outcome = Outcome.Failure("failure") + // Act + failureOutcome.handleAsync( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + // Assert + assertThat(successCalledWith).isNull() + assertThat(failureCalledWith).isEqualTo("failure") + } + + @Test + fun `given Success when fold is called then returns success result`() { + // Arrange + val outcome: Outcome = Outcome.Success(10) + + // Act + val result = outcome.fold( + onSuccess = { it * 3 }, + onFailure = { _ -> -1 }, + ) + + // Assert + assertThat(result).isEqualTo(30) + } + + @Test + fun `given Failure when fold is called then returns failure result`() { + // Arrange + val outcome: Outcome = Outcome.Failure("oops") + + // Act + val result = outcome.fold( + onSuccess = { it * 3 }, + onFailure = { err -> err + "!" }, + ) + + // Assert + assertThat(result).isEqualTo("oops!") + } +} diff --git a/components/core/version.properties b/components/core/version.properties new file mode 100644 index 0000000..0e288f2 --- /dev/null +++ b/components/core/version.properties @@ -0,0 +1,3 @@ +MAJOR=0 +MINOR=1 +PATCH=0 diff --git a/settings.gradle.kts b/settings.gradle.kts index cbdf384..89e8608 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,10 +18,6 @@ pluginManagement { includeBuild("build-plugin") } -include(":components:bom") - -include(":quality:konsist") - dependencyResolutionManagement { repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS repositories { @@ -40,6 +36,15 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } +include(":components:bom") + +// Core +include( + ":components:core:outcome", +) + +include(":quality:konsist") + check(JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) { """ Java 17+ is required to build Thunderbird for Android. From ec59320de5179a50202961a3ee0506144063951a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Mon, 22 Jun 2026 14:22:41 +0200 Subject: [PATCH 2/5] refactor(core-outcome): add testBalloon plugin and convert test # Conflicts: # gradle/libs.versions.toml --- components/core/outcome/build.gradle.kts | 7 +- .../components/core/outcome/OutcomeTest.kt | 366 ++++++++++-------- gradle/libs.versions.toml | 4 + 3 files changed, 215 insertions(+), 162 deletions(-) diff --git a/components/core/outcome/build.gradle.kts b/components/core/outcome/build.gradle.kts index 42b2184..b13fb7b 100644 --- a/components/core/outcome/build.gradle.kts +++ b/components/core/outcome/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.tb.library.kmp) + alias(libs.plugins.testBalloon) } kotlin { @@ -7,12 +8,6 @@ kotlin { android { namespace = "net.thunderbird.components.core.outcome" } - sourceSets { - commonTest.dependencies { - implementation(libs.assertk) - implementation(libs.kotlinx.coroutines.test) - } - } } codeCoverage { diff --git a/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt b/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt index 1cbc7bf..17e9dfb 100644 --- a/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt +++ b/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt @@ -5,195 +5,249 @@ import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isNull import assertk.assertions.isTrue -import kotlin.test.Test -import kotlinx.coroutines.test.runTest +import de.infix.testBalloon.framework.core.testSuite -class OutcomeTest { +val outcomeTest by testSuite("Outcome") { - @Test - fun `given Success when checking then has correct flags and data`() { - // Arrange - val outcome: Outcome = Outcome.success(42) + testSuite("factory functions") { + test("success creates a successful outcome") { + // Arrange + val outcome: Outcome = Outcome.success(42) - // Act + Assert - assertThat(outcome.isSuccess).isTrue() - assertThat(outcome.isFailure).isFalse() - val data = (outcome as Outcome.Success).data - assertThat(data).isEqualTo(42) - } + // Act + val success = outcome as Outcome.Success + + // Assert + assertThat(outcome.isSuccess).isTrue() + assertThat(outcome.isFailure).isFalse() + assertThat(success.data).isEqualTo(42) + } - @Test - fun `given Failure when checking then has correct flags and error`() { - // Arrange - val outcome: Outcome = Outcome.failure("error") + test("failure creates a failed outcome") { + // Arrange + val outcome: Outcome = Outcome.failure("error") - // Act + Assert - assertThat(outcome.isFailure).isTrue() - assertThat(outcome.isSuccess).isFalse() - val error = (outcome as Outcome.Failure).error - assertThat(error).isEqualTo("error") + // Act + val failure = outcome as Outcome.Failure + + // Assert + assertThat(outcome.isFailure).isTrue() + assertThat(outcome.isSuccess).isFalse() + assertThat(failure.error).isEqualTo("error") + } } - @Test - fun `given Success when map is called then transforms success value`() { - // Arrange - val outcome: Outcome = Outcome.Success(7) + testSuite("map") { + test("transforms success value") { + // Arrange + val outcome: Outcome = Outcome.Success(7) - // Act - val mapped = outcome.map( - transformSuccess = { it * 2 }, - transformFailure = { err, _ -> "$err!" }, - ) + // Act + val mapped = outcome.map( + transformSuccess = { value -> value * 2 }, + transformFailure = { error, _ -> "$error!" }, + ) - // Assert - val data = (mapped as Outcome.Success).data - assertThat(data).isEqualTo(14) + // Assert + assertThat((mapped as Outcome.Success).data).isEqualTo(14) + } + + test("transforms failure value and passes cause") { + // Arrange + val cause = IllegalStateException("cause") + val outcome: Outcome = Outcome.Failure("error", cause) + + // Act + val mapped = outcome.map( + transformSuccess = { value -> value * 2 }, + transformFailure = { error, receivedCause -> + assertThat(receivedCause).isEqualTo(cause) + "$error-transformed" + }, + ) + + // Assert + assertThat((mapped as Outcome.Failure).error).isEqualTo("error-transformed") + } } - @Test - fun `given Failure with cause when map is called then transforms failure value and provides cause`() { - // Arrange - val cause = IllegalStateException("cause") - val outcome: Outcome = Outcome.Failure("error", cause) + testSuite("mapSuccess") { + test("transforms success value") { + // Arrange + val outcome = Outcome.Success(3) - // Act - val mapped = outcome.map( - transformSuccess = { it * 2 }, - transformFailure = { err, receivedCause -> - assertThat(receivedCause).isEqualTo(cause) - "$err-transformed" - }, - ) + // Act + val mapped = outcome.mapSuccess { value -> value + 1 } - // Assert - val failure = (mapped as Outcome.Failure) - assertThat(failure.error).isEqualTo("error-transformed") - } + // Assert + assertThat((mapped as Outcome.Success).data).isEqualTo(4) + } - @Test - fun `given Success and Failure when mapSuccess is called then only success is transformed`() { - // Arrange & Act - val success = Outcome.Success(3).mapSuccess { it + 1 } - val failure = Outcome.Failure("failure").mapSuccess { 999 } + test("passes failure through unchanged") { + // Arrange + val outcome = Outcome.Failure("failure") - // Assert - assertThat((success as Outcome.Success).data).isEqualTo(4) - // Failure must be unchanged - assertThat((failure as Outcome.Failure).error).isEqualTo("failure") + // Act + val mapped = outcome.mapSuccess { 999 } + + // Assert + assertThat((mapped as Outcome.Failure).error).isEqualTo("failure") + } } - @Test - fun `given Success and Failure when flatMapSuccess is called then success is flat-mapped and failure passes through`() { - // Arrange & Act - val onSuccess = Outcome.Success(10).flatMapSuccess { value -> - if (value > 5) Outcome.Success("success") else Outcome.Failure("failure") + testSuite("flatMapSuccess") { + test("flat maps success value") { + // Arrange + val outcome = Outcome.Success(10) + + // Act + val mapped = outcome.flatMapSuccess { value -> + if (value > 5) Outcome.Success("success") else Outcome.Failure("failure") + } + + // Assert + assertThat((mapped as Outcome.Success).data).isEqualTo("success") } - val onFailure: Outcome = - Outcome.Failure("failure").flatMapSuccess { Outcome.Success("won't happen") } - // Assert - assertThat((onSuccess as Outcome.Success).data).isEqualTo("success") - assertThat((onFailure as Outcome.Failure).error).isEqualTo("failure") + test("passes failure through unchanged") { + // Arrange + val outcome: Outcome = Outcome.Failure("failure") + + // Act + val mapped = outcome.flatMapSuccess { Outcome.Success("unused") } + + // Assert + assertThat((mapped as Outcome.Failure).error).isEqualTo("failure") + } } - @Test - fun `given Success and Failure when mapFailure is called then only failure is transformed and cause provided`() { - // Arrange & Act - val cause = RuntimeException("cause") - val success = Outcome.Success("success").mapFailure { e: String, _ -> "$e?" } - val failure = Outcome.Failure("fail", cause).mapFailure { e, c -> - assertThat(c).isEqualTo(cause) - 999 + testSuite("mapFailure") { + test("passes success through unchanged") { + // Arrange + val outcome = Outcome.Success("success") + + // Act + val mapped = outcome.mapFailure { error: String, _ -> "$error?" } + + // Assert + assertThat((mapped as Outcome.Success).data).isEqualTo("success") } - // Assert - assertThat((success as Outcome.Success).data).isEqualTo("success") - assertThat((failure as Outcome.Failure).error).isEqualTo(999) + test("transforms failure value and passes cause") { + // Arrange + val cause = RuntimeException("cause") + val outcome = Outcome.Failure("fail", cause) + + // Act + val mapped = outcome.mapFailure { error, receivedCause -> + assertThat(receivedCause).isEqualTo(cause) + error.length + } + + // Assert + assertThat((mapped as Outcome.Failure).error).isEqualTo(4) + } } - @Test - fun `given Outcome when handle is invoked then calls only the matching callback`() { - // Arrange - var successCalledWith: Int? = null - var failureCalledWith: String? = null - - // Act - Outcome.Success(5).handle( - onSuccess = { successCalledWith = it }, - onFailure = { failureCalledWith = it }, - ) - // Assert - assertThat(successCalledWith).isEqualTo(5) - assertThat(failureCalledWith).isNull() - - // Arrange - successCalledWith = null - val failureOutcome: Outcome = Outcome.Failure("failure") - // Act - failureOutcome.handle( - onSuccess = { successCalledWith = it }, - onFailure = { failureCalledWith = it }, - ) - // Assert - assertThat(successCalledWith).isNull() - assertThat(failureCalledWith).isEqualTo("failure") + testSuite("handle") { + test("calls only success callback") { + // Arrange + var successCalledWith: Int? = null + var failureCalledWith: String? = null + val outcome = Outcome.Success(5) + + // Act + outcome.handle( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + + // Assert + assertThat(successCalledWith).isEqualTo(5) + assertThat(failureCalledWith).isNull() + } + + test("calls only failure callback") { + // Arrange + var successCalledWith: Int? = null + var failureCalledWith: String? = null + val outcome: Outcome = Outcome.Failure("failure") + + // Act + outcome.handle( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + + // Assert + assertThat(successCalledWith).isNull() + assertThat(failureCalledWith).isEqualTo("failure") + } } - @Test - fun `given Outcome when handleAsync is invoked then calls only the matching suspending callback`() = runTest { - // Arrange - var successCalledWith: Int? = null - var failureCalledWith: String? = null - - // Act - Outcome.Success(1).handleAsync( - onSuccess = { successCalledWith = it }, - onFailure = { failureCalledWith = it }, - ) - // Assert - assertThat(successCalledWith).isEqualTo(1) - assertThat(failureCalledWith).isNull() - - // Arrange - successCalledWith = null - val failureOutcome: Outcome = Outcome.Failure("failure") - // Act - failureOutcome.handleAsync( - onSuccess = { successCalledWith = it }, - onFailure = { failureCalledWith = it }, - ) - // Assert - assertThat(successCalledWith).isNull() - assertThat(failureCalledWith).isEqualTo("failure") + testSuite("handleAsync") { + test("calls only suspending success callback") { + // Arrange + var successCalledWith: Int? = null + var failureCalledWith: String? = null + val outcome = Outcome.Success(1) + + // Act + outcome.handleAsync( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + + // Assert + assertThat(successCalledWith).isEqualTo(1) + assertThat(failureCalledWith).isNull() + } + + test("calls only suspending failure callback") { + // Arrange + var successCalledWith: Int? = null + var failureCalledWith: String? = null + val outcome: Outcome = Outcome.Failure("failure") + + // Act + outcome.handleAsync( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + + // Assert + assertThat(successCalledWith).isNull() + assertThat(failureCalledWith).isEqualTo("failure") + } } - @Test - fun `given Success when fold is called then returns success result`() { - // Arrange - val outcome: Outcome = Outcome.Success(10) + testSuite("fold") { + test("returns success result") { + // Arrange + val outcome: Outcome = Outcome.Success(10) - // Act - val result = outcome.fold( - onSuccess = { it * 3 }, - onFailure = { _ -> -1 }, - ) + // Act + val result = outcome.fold( + onSuccess = { value -> value * 3 }, + onFailure = { -1 }, + ) - // Assert - assertThat(result).isEqualTo(30) - } + // Assert + assertThat(result).isEqualTo(30) + } - @Test - fun `given Failure when fold is called then returns failure result`() { - // Arrange - val outcome: Outcome = Outcome.Failure("oops") + test("returns failure result") { + // Arrange + val outcome: Outcome = Outcome.Failure("oops") - // Act - val result = outcome.fold( - onSuccess = { it * 3 }, - onFailure = { err -> err + "!" }, - ) + // Act + val result = outcome.fold( + onSuccess = { value -> value * 3 }, + onFailure = { error -> "$error!" }, + ) - // Assert - assertThat(result).isEqualTo("oops!") + // Assert + assertThat(result).isEqualTo("oops!") + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 086a7c5..e9318f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ kover = "0.9.8" mavenPublish = "0.37.0" robolectric = "4.16.1" spotlessPlugin = "8.7.0" +testBalloon = "1.0.1-K2.4.0" turbine = "1.2.1" [plugins] @@ -51,6 +52,7 @@ kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } ksp = { id = "com.google.devtools.ksp", version.ref = "kotlinKsp" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } spotless = { id = "com.diffplug.spotless", version.ref = "spotlessPlugin" } +testBalloon = { id = "de.infix.testBalloon", version.ref = "testBalloon" } # Build plugins tb-app-kmp-compose = { id = "net.thunderbird.gradle.plugin.app.kmp.compose" } @@ -91,6 +93,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDateTime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +testBalloon-framework-core = { module = "de.infix.testBalloon:testBalloon-framework-core", version.ref = "testBalloon" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } [bundles] @@ -113,6 +116,7 @@ shared-kmp-common-test = [ "kotlin-test", "kotlinx-coroutines-test", "turbine", + "testBalloon-framework-core", ] shared-kmp-android-test = [ ] From edc11a72aad3d6da7cf7f8977a82f36781eff000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Tue, 23 Jun 2026 16:26:38 +0200 Subject: [PATCH 3/5] test: improve OutcomeTest coverage add workaround for Kover not properly counting inline functions --- components/core/outcome/api/jvm/outcome.api | 55 ++++++ components/core/outcome/build.gradle.kts | 5 - .../components/core/outcome/OutcomeTest.kt | 48 ++++- .../core/outcome/OutcomeJvmCoverageTest.kt | 179 ++++++++++++++++++ 4 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 components/core/outcome/src/jvmTest/kotlin/net/thunderbird/components/core/outcome/OutcomeJvmCoverageTest.kt diff --git a/components/core/outcome/api/jvm/outcome.api b/components/core/outcome/api/jvm/outcome.api index e69de29..4481233 100644 --- a/components/core/outcome/api/jvm/outcome.api +++ b/components/core/outcome/api/jvm/outcome.api @@ -0,0 +1,55 @@ +public abstract interface class net/thunderbird/components/core/outcome/Outcome { + public static final field Companion Lnet/thunderbird/components/core/outcome/Outcome$Companion; + public fun isFailure ()Z + public fun isSuccess ()Z +} + +public final class net/thunderbird/components/core/outcome/Outcome$Companion { + public final fun failure (Ljava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome; + public final fun success (Ljava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome; +} + +public final class net/thunderbird/components/core/outcome/Outcome$DefaultImpls { + public static fun isFailure (Lnet/thunderbird/components/core/outcome/Outcome;)Z + public static fun isSuccess (Lnet/thunderbird/components/core/outcome/Outcome;)Z +} + +public final class net/thunderbird/components/core/outcome/Outcome$Failure : net/thunderbird/components/core/outcome/Outcome { + public fun (Ljava/lang/Object;Ljava/lang/Object;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/Object;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;Ljava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome$Failure; + public static synthetic fun copy$default (Lnet/thunderbird/components/core/outcome/Outcome$Failure;Ljava/lang/Object;Ljava/lang/Object;ILjava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome$Failure; + public fun equals (Ljava/lang/Object;)Z + public final fun getCause ()Ljava/lang/Object; + public final fun getError ()Ljava/lang/Object; + public fun hashCode ()I + public fun isFailure ()Z + public fun isSuccess ()Z + public fun toString ()Ljava/lang/String; +} + +public final class net/thunderbird/components/core/outcome/Outcome$Success : net/thunderbird/components/core/outcome/Outcome { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome$Success; + public static synthetic fun copy$default (Lnet/thunderbird/components/core/outcome/Outcome$Success;Ljava/lang/Object;ILjava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome$Success; + public fun equals (Ljava/lang/Object;)Z + public final fun getData ()Ljava/lang/Object; + public fun hashCode ()I + public fun isFailure ()Z + public fun isSuccess ()Z + public fun toString ()Ljava/lang/String; +} + +public final class net/thunderbird/components/core/outcome/OutcomeKt { + public static final fun flatMapSuccess (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function1;)Lnet/thunderbird/components/core/outcome/Outcome; + public static final fun fold (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static final fun handle (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public static final fun handleAsync (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun map (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lnet/thunderbird/components/core/outcome/Outcome; + public static final fun mapFailure (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function2;)Lnet/thunderbird/components/core/outcome/Outcome; + public static final fun mapSuccess (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function1;)Lnet/thunderbird/components/core/outcome/Outcome; +} + diff --git a/components/core/outcome/build.gradle.kts b/components/core/outcome/build.gradle.kts index b13fb7b..31aeb10 100644 --- a/components/core/outcome/build.gradle.kts +++ b/components/core/outcome/build.gradle.kts @@ -9,8 +9,3 @@ kotlin { namespace = "net.thunderbird.components.core.outcome" } } - -codeCoverage { - branchCoverage = 28 - lineCoverage = 53 -} diff --git a/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt b/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt index 17e9dfb..bd13d64 100644 --- a/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt +++ b/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt @@ -35,6 +35,28 @@ val outcomeTest by testSuite("Outcome") { assertThat(outcome.isSuccess).isFalse() assertThat(failure.error).isEqualTo("error") } + + test("failure factory creates a failure without cause") { + // Arrange + val outcome: Outcome = Outcome.failure("error") + + // Act + val failure = outcome as Outcome.Failure + + // Assert + assertThat(failure.cause).isNull() + } + + test("failure data class keeps cause") { + // Arrange + val cause = IllegalArgumentException("cause") + + // Act + val failure = Outcome.Failure("error", cause) + + // Assert + assertThat(failure.cause === cause).isTrue() + } } testSuite("map") { @@ -85,13 +107,16 @@ val outcomeTest by testSuite("Outcome") { test("passes failure through unchanged") { // Arrange - val outcome = Outcome.Failure("failure") + val cause = IllegalStateException("cause") + val outcome = Outcome.Failure("failure", cause) // Act val mapped = outcome.mapSuccess { 999 } // Assert + assertThat(mapped === outcome).isTrue() assertThat((mapped as Outcome.Failure).error).isEqualTo("failure") + assertThat(mapped.cause === cause).isTrue() } } @@ -109,15 +134,33 @@ val outcomeTest by testSuite("Outcome") { assertThat((mapped as Outcome.Success).data).isEqualTo("success") } + test("flat maps success value to failure") { + // Arrange + val cause = IllegalArgumentException("cause") + val outcome = Outcome.Success(1) + + // Act + val mapped = outcome.flatMapSuccess { + Outcome.Failure("failure", cause) + } + + // Assert + assertThat((mapped as Outcome.Failure).error).isEqualTo("failure") + assertThat(mapped.cause === cause).isTrue() + } + test("passes failure through unchanged") { // Arrange - val outcome: Outcome = Outcome.Failure("failure") + val cause = IllegalStateException("cause") + val outcome: Outcome = Outcome.Failure("failure", cause) // Act val mapped = outcome.flatMapSuccess { Outcome.Success("unused") } // Assert + assertThat(mapped === outcome).isTrue() assertThat((mapped as Outcome.Failure).error).isEqualTo("failure") + assertThat(mapped.cause === cause).isTrue() } } @@ -130,6 +173,7 @@ val outcomeTest by testSuite("Outcome") { val mapped = outcome.mapFailure { error: String, _ -> "$error?" } // Assert + assertThat(mapped === outcome).isTrue() assertThat((mapped as Outcome.Success).data).isEqualTo("success") } diff --git a/components/core/outcome/src/jvmTest/kotlin/net/thunderbird/components/core/outcome/OutcomeJvmCoverageTest.kt b/components/core/outcome/src/jvmTest/kotlin/net/thunderbird/components/core/outcome/OutcomeJvmCoverageTest.kt new file mode 100644 index 0000000..c39d97e --- /dev/null +++ b/components/core/outcome/src/jvmTest/kotlin/net/thunderbird/components/core/outcome/OutcomeJvmCoverageTest.kt @@ -0,0 +1,179 @@ +package net.thunderbird.components.core.outcome + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue +import de.infix.testBalloon.framework.core.testSuite + +// Kover does not attribute normal Kotlin call-site coverage to inline function bodies. +// These JVM-only tests invoke the generated static methods directly so Gradle coverage sees both branches. +// Related issue: https://github.com/Kotlin/kotlinx-kover/issues/753 +val outcomeJvmCoverageTest by testSuite("Outcome JVM coverage") { + + test("reflectively covers inline map branches") { + // Arrange + val success = Outcome.Success(7) + val cause = IllegalStateException("cause") + val failure: Outcome = Outcome.Failure("error", cause) + + // Act + val mappedSuccess = invokeMap( + outcome = success, + transformSuccess = { value: Int -> value * 2 }, + transformFailure = { error: String, _ -> "$error!" }, + ) + val mappedFailure = invokeMap( + outcome = failure, + transformSuccess = { value: Int -> value * 2 }, + transformFailure = { error: String, receivedCause -> + assertThat(receivedCause === cause).isTrue() + "$error-transformed" + }, + ) + + // Assert + assertThat((mappedSuccess as Outcome.Success).data).isEqualTo(14) + assertThat((mappedFailure as Outcome.Failure).error).isEqualTo("error-transformed") + } + + test("reflectively covers inline mapSuccess branches") { + // Arrange + val success = Outcome.Success(3) + val failure = Outcome.Failure("failure") + + // Act + val mappedSuccess = invokeMapSuccess( + outcome = success, + transformSuccess = { value: Int -> value + 1 }, + ) + val mappedFailure = invokeMapSuccess( + outcome = failure, + transformSuccess = { 999 }, + ) + + // Assert + assertThat((mappedSuccess as Outcome.Success).data).isEqualTo(4) + assertThat(mappedFailure === failure).isTrue() + } + + test("reflectively covers inline flatMapSuccess branches") { + // Arrange + val success = Outcome.Success(10) + val failure: Outcome = Outcome.Failure("failure") + + // Act + val mappedSuccess = invokeFlatMapSuccess( + outcome = success, + transformSuccess = { Outcome.Success("success") }, + ) + val mappedFailure = invokeFlatMapSuccess( + outcome = failure, + transformSuccess = { Outcome.Success("unused") }, + ) + + // Assert + assertThat((mappedSuccess as Outcome.Success).data).isEqualTo("success") + assertThat(mappedFailure === failure).isTrue() + } + + test("reflectively covers inline mapFailure branches") { + // Arrange + val success = Outcome.Success("success") + val cause = RuntimeException("cause") + val failure = Outcome.Failure("fail", cause) + + // Act + val mappedSuccess = invokeMapFailure( + outcome = success, + transformFailure = { error: String, _ -> "$error?" }, + ) + val mappedFailure = invokeMapFailure( + outcome = failure, + transformFailure = { error: String, receivedCause -> + assertThat(receivedCause === cause).isTrue() + error.length + }, + ) + + // Assert + assertThat(mappedSuccess === success).isTrue() + assertThat((mappedFailure as Outcome.Failure).error).isEqualTo(4) + } + + test("reflectively covers inline fold branches") { + // Arrange + val success = Outcome.Success(10) + val failure = Outcome.Failure("oops") + + // Act + val successResult = invokeFold( + outcome = success, + onSuccess = { value: Int -> value * 3 }, + onFailure = { -1 }, + ) + val failureResult = invokeFold( + outcome = failure, + onSuccess = { value: Int -> value * 3 }, + onFailure = { error: String -> "$error!" }, + ) + + // Assert + assertThat(successResult).isEqualTo(30) + assertThat(failureResult).isEqualTo("oops!") + } +} + +@Suppress("UNCHECKED_CAST") +private fun invokeMap( + outcome: Outcome, + transformSuccess: (IN_SUCCESS) -> OUT_SUCCESS, + transformFailure: (IN_FAILURE, Any?) -> OUT_FAILURE, +): Outcome { + return invokeOutcomeKt("map", outcome, transformSuccess, transformFailure) as Outcome +} + +@Suppress("UNCHECKED_CAST") +private fun invokeMapSuccess( + outcome: Outcome, + transformSuccess: (IN_SUCCESS) -> OUT_SUCCESS, +): Outcome { + return invokeOutcomeKt("mapSuccess", outcome, transformSuccess) as Outcome +} + +@Suppress("UNCHECKED_CAST") +private fun invokeFlatMapSuccess( + outcome: Outcome, + transformSuccess: (IN_SUCCESS) -> Outcome, +): Outcome { + return invokeOutcomeKt("flatMapSuccess", outcome, transformSuccess) as Outcome +} + +@Suppress("UNCHECKED_CAST") +private fun invokeMapFailure( + outcome: Outcome, + transformFailure: (IN_FAILURE, Any?) -> OUT_FAILURE, +): Outcome { + return invokeOutcomeKt("mapFailure", outcome, transformFailure) as Outcome +} + +@Suppress("UNCHECKED_CAST") +private fun invokeFold( + outcome: Outcome, + onSuccess: (SUCCESS) -> R, + onFailure: (FAILURE) -> R, +): R { + return invokeOutcomeKt("fold", outcome, onSuccess, onFailure) as R +} + +private fun invokeOutcomeKt( + methodName: String, + outcome: Outcome<*, *>, + vararg functions: Any, +): Any { + val functionTypes = functions.map { it.javaClass.interfaces.single() }.toTypedArray() + val method = outcomeKtClass.getDeclaredMethod(methodName, Outcome::class.java, *functionTypes) + return method.invoke(null, outcome, *functions) + ?: error("Reflective invocation of $methodName returned null.") +} + +private val outcomeKtClass: Class<*> = Class.forName("net.thunderbird.components.core.outcome.OutcomeKt") From 4bf5b141276110010f2aa055062374bea95c6ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Tue, 23 Jun 2026 18:44:17 +0200 Subject: [PATCH 4/5] fix(build): run detektAll during check --- .../gradle/plugin/quality/detekt/DetektPlugin.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt index a0c448a..97caf68 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt @@ -9,6 +9,8 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.assign import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.named +import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.withType /** @@ -19,6 +21,7 @@ import org.gradle.kotlin.dsl.withType class DetektPlugin : Plugin { override fun apply(target: Project) { with(target) { + pluginManager.apply("base") pluginManager.apply("dev.detekt") dependencies { @@ -56,8 +59,6 @@ class DetektPlugin : Plugin { sarif.required.set(true) markdown.required.set(true) } - - tasks.getByName("build").dependsOn(this) } withType().configureEach { @@ -70,12 +71,16 @@ class DetektPlugin : Plugin { exclude(defaultExcludes) } - register("detektAll") { + val detektAll = register("detektAll") { group = "verification" description = "Runs detekt on this project" dependsOn(tasks.withType()) } + + named("check") { + dependsOn(detektAll) + } } } } From 51261fd32b9d4112a60c9eec56deef89eaefdb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Tue, 23 Jun 2026 18:54:04 +0200 Subject: [PATCH 5/5] chore(build): derive kmp library android namespace from path --- .../library/kmp/KotlinMultiplatformExtension.kt | 13 +++++++++++++ .../gradle/plugin/library/kmp/LibraryKmpPlugin.kt | 1 + .../library/kmp/compose/LibraryKmpComposePlugin.kt | 2 ++ components/core/outcome/build.gradle.kts | 7 ------- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt index 5d631c2..fe99d0a 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt @@ -1,9 +1,11 @@ package net.thunderbird.gradle.plugin.library.kmp import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget +import net.thunderbird.gradle.plugin.ProjectConfig import org.gradle.api.Action import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.NamedDomainObjectProvider +import org.gradle.api.Project import org.gradle.api.artifacts.ExternalModuleDependency import org.gradle.api.artifacts.MinimalExternalModuleDependency import org.gradle.api.plugins.ExtensionAware @@ -20,6 +22,17 @@ fun KotlinMultiplatformExtension.android(configure: Action { } android { + namespaceByPath(target) minSdk = ProjectConfig.Android.sdkMin compileSdk = ProjectConfig.Android.sdkCompile diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt index 83f80b7..580a1e7 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt @@ -3,6 +3,7 @@ package net.thunderbird.gradle.plugin.library.kmp.compose import net.thunderbird.gradle.plugin.ProjectConfig import net.thunderbird.gradle.plugin.library.kmp.android import net.thunderbird.gradle.plugin.library.kmp.androidHostTest +import net.thunderbird.gradle.plugin.library.kmp.namespaceByPath import net.thunderbird.gradle.plugin.libs import org.gradle.api.Plugin import org.gradle.api.Project @@ -48,6 +49,7 @@ class LibraryKmpComposePlugin : Plugin { } android { + namespaceByPath(target) minSdk = ProjectConfig.Android.sdkMin compileSdk = ProjectConfig.Android.sdkCompile diff --git a/components/core/outcome/build.gradle.kts b/components/core/outcome/build.gradle.kts index 31aeb10..8b436eb 100644 --- a/components/core/outcome/build.gradle.kts +++ b/components/core/outcome/build.gradle.kts @@ -2,10 +2,3 @@ plugins { alias(libs.plugins.tb.library.kmp) alias(libs.plugins.testBalloon) } - -kotlin { - @Suppress("UnstableApiUsage") - android { - namespace = "net.thunderbird.components.core.outcome" - } -}