First Commit
This commit is contained in:
1
tests/detekt-rules/.gitignore
vendored
Normal file
1
tests/detekt-rules/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
20
tests/detekt-rules/build.gradle.kts
Normal file
20
tests/detekt-rules/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
java {
|
||||
sourceCompatibility = Versions.javaVersion
|
||||
targetCompatibility = Versions.javaVersion
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion = Versions.javaLanguageVersion
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(libs.test.detekt.api)
|
||||
testImplementation(libs.test.detekt.test)
|
||||
|
||||
testImplementation(libs.test.truth)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.detektrules
|
||||
|
||||
import io.github.detekt.psi.fileName
|
||||
import io.gitlab.arturbosch.detekt.api.CodeSmell
|
||||
import io.gitlab.arturbosch.detekt.api.Config
|
||||
import io.gitlab.arturbosch.detekt.api.Debt
|
||||
import io.gitlab.arturbosch.detekt.api.Entity
|
||||
import io.gitlab.arturbosch.detekt.api.Issue
|
||||
import io.gitlab.arturbosch.detekt.api.Rule
|
||||
import io.gitlab.arturbosch.detekt.api.Severity
|
||||
import org.jetbrains.kotlin.psi.KtPropertyDelegate
|
||||
|
||||
class ByPreferencesDataStoreRule(config: Config) : Rule(config) {
|
||||
override val issue: Issue = Issue(
|
||||
id = "ByPreferencesDataStoreNotAllowed",
|
||||
severity = Severity.Style,
|
||||
description = "Avoid using `by preferencesDataStore(...)`, use `PreferenceDataStoreFactory.create(name)`instead.",
|
||||
debt = Debt.FIVE_MINS,
|
||||
)
|
||||
|
||||
override fun visitPropertyDelegate(delegate: KtPropertyDelegate) {
|
||||
super.visitPropertyDelegate(delegate)
|
||||
|
||||
if (delegate.containingKtFile.fileName == "DefaultPreferencesDataStoreFactory.kt") {
|
||||
// Skip the rule for the DefaultPreferencesDataStoreFactory implementation
|
||||
return
|
||||
}
|
||||
|
||||
if (delegate.text.startsWith("by preferencesDataStore")) {
|
||||
report(CodeSmell(
|
||||
issue = issue,
|
||||
entity = Entity.from(delegate),
|
||||
message = "Use `PreferenceDataStoreFactory.create(name)` instead of `by preferencesDataStore(...)`."
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.detektrules
|
||||
|
||||
import io.gitlab.arturbosch.detekt.api.Config
|
||||
import io.gitlab.arturbosch.detekt.api.RuleSet
|
||||
import io.gitlab.arturbosch.detekt.api.RuleSetProvider
|
||||
|
||||
class ElementRuleSetProvider : RuleSetProvider {
|
||||
override val ruleSetId: String = "ElementXRules"
|
||||
|
||||
override fun instance(config: Config): RuleSet = RuleSet(
|
||||
id = ruleSetId,
|
||||
rules = listOf(
|
||||
RunCatchingRule(config),
|
||||
ByPreferencesDataStoreRule(config),
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.detektrules
|
||||
|
||||
import io.gitlab.arturbosch.detekt.api.CodeSmell
|
||||
import io.gitlab.arturbosch.detekt.api.Config
|
||||
import io.gitlab.arturbosch.detekt.api.Debt
|
||||
import io.gitlab.arturbosch.detekt.api.Entity
|
||||
import io.gitlab.arturbosch.detekt.api.Issue
|
||||
import io.gitlab.arturbosch.detekt.api.Rule
|
||||
import io.gitlab.arturbosch.detekt.api.Severity
|
||||
import org.jetbrains.kotlin.psi.KtCallExpression
|
||||
import org.jetbrains.kotlin.psi.psiUtil.getCallNameExpression
|
||||
|
||||
class RunCatchingRule(config: Config) : Rule(config) {
|
||||
override val issue: Issue = Issue(
|
||||
id = "RunCatchingNotAllowed",
|
||||
severity = Severity.Style,
|
||||
description = "Avoid using `runCatching`, use `runCatchingExceptions` or `tryOrNull` instead. " +
|
||||
"Avoid `mapCatching`, use `mapCatchingExceptions` instead.",
|
||||
debt = Debt.FIVE_MINS,
|
||||
)
|
||||
|
||||
override fun visitCallExpression(expression: KtCallExpression) {
|
||||
super.visitCallExpression(expression)
|
||||
|
||||
val callNameExpression = expression.getCallNameExpression() ?: return
|
||||
val hasRunCatchingCall = callNameExpression.text == "runCatching"
|
||||
val hasMapCatchingCall = callNameExpression.text == "mapCatching"
|
||||
if (hasRunCatchingCall || hasMapCatchingCall) {
|
||||
report(CodeSmell(
|
||||
issue = issue,
|
||||
entity = Entity.from(expression),
|
||||
message = "Use `runCatchingExceptions` or `tryOrNull` instead of `runCatching`. Avoid `mapCatching`, use `mapCatchingExceptions` instead."
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
io.element.android.detektrules.ElementRuleSetProvider
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.detektrules
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.gitlab.arturbosch.detekt.api.Config
|
||||
import io.gitlab.arturbosch.detekt.test.compileAndLint
|
||||
import org.junit.Test
|
||||
|
||||
class RunCatchingRuleTest {
|
||||
private val subject = RunCatchingRule(Config.empty)
|
||||
|
||||
@Test
|
||||
fun `test RunCatchingRule`() {
|
||||
val findings = subject.compileAndLint(code)
|
||||
assertThat(findings).hasSize(3)
|
||||
}
|
||||
|
||||
private val code = """
|
||||
object Foo {
|
||||
fun bar() {
|
||||
runCatching {}
|
||||
kotlin.runCatching {}
|
||||
Result.success(true).mapCatching { false }
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
34
tests/konsist/build.gradle.kts
Normal file
34
tests/konsist/build.gradle.kts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.tests.konsist"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
testImplementation(composeBom)
|
||||
testImplementation(libs.androidx.compose.ui.tooling.preview)
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.konsist)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(projects.libraries.architecture)
|
||||
testImplementation(projects.libraries.designsystem)
|
||||
}
|
||||
|
||||
// Make sure Konsist tests run for 'check' tasks. This is needed because otherwise we'd have to either:
|
||||
// - Add every single module as a dependency of this one.
|
||||
// - Move the Konsist tests to the `app` module, but the `app` module does not need to know about Konsist.
|
||||
tasks.withType<Test>().configureEach {
|
||||
val isNotCheckTask = gradle.startParameter.taskNames.any { it.contains("check", ignoreCase = true).not() }
|
||||
outputs.upToDateWhen { isNotCheckTask }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist.failures
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
// Make test `Sealed interface used in Composable MUST be Immutable or Stable` fails
|
||||
|
||||
sealed interface SealedInterface
|
||||
|
||||
@Composable
|
||||
fun FailingComposableWithNonImmutableSealedInterface(
|
||||
sealedInterface: SealedInterface
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist.failures
|
||||
|
||||
// Make test `Fake classes must be named using Fake and the interface it fakes` fails
|
||||
|
||||
interface MyInterface
|
||||
|
||||
// This class should be named FakeMyInterface
|
||||
class FakeWrongClassName : MyInterface
|
||||
|
||||
class MyClass {
|
||||
interface MyFactory
|
||||
}
|
||||
|
||||
// This class should be named FakeMyClassMyFactory
|
||||
class FakeWrongClassSubInterfaceName : MyClass.MyFactory
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.constructors
|
||||
import com.lemonappdev.konsist.api.ext.list.modifierprovider.withSealedModifier
|
||||
import com.lemonappdev.konsist.api.ext.list.parameters
|
||||
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
|
||||
import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutAnnotationOf
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutConstructors
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutName
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutParents
|
||||
import com.lemonappdev.konsist.api.verify.assertEmpty
|
||||
import com.lemonappdev.konsist.api.verify.assertTrue
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistArchitectureTest {
|
||||
@Test
|
||||
fun `Data class state MUST not have default value`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.classes()
|
||||
.withNameEndingWith("State")
|
||||
.withoutName(
|
||||
"CameraPositionState",
|
||||
"CustomSheetState",
|
||||
)
|
||||
.constructors
|
||||
.parameters
|
||||
.assertTrue { parameterDeclaration ->
|
||||
parameterDeclaration.defaultValue == null &&
|
||||
// Using parameterDeclaration.defaultValue == null is not enough apparently,
|
||||
// Also check that the text does not contain an equal sign
|
||||
parameterDeclaration.text.contains("=").not()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Events MUST be sealed interface`() {
|
||||
Konsist.scopeFromProject()
|
||||
.classes()
|
||||
.withSealedModifier()
|
||||
.withNameEndingWith("Events")
|
||||
.assertEmpty(additionalMessage = "Events class MUST be sealed interface")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Sealed class without constructor and without parent MUST be sealed interface`() {
|
||||
Konsist.scopeFromProject()
|
||||
.classes()
|
||||
.withSealedModifier()
|
||||
.withoutConstructors()
|
||||
.withoutParents()
|
||||
.assertEmpty(additionalMessage = "Sealed class without constructor MUST be sealed interface")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Sealed interface used in Composable MUST be Immutable or Stable`() {
|
||||
var failingTestFound = false
|
||||
// List all sealed interface without Immutable nor Stable annotation in the project
|
||||
val forbiddenInterfacesForComposableParameter = Konsist.scopeFromProject()
|
||||
.interfaces()
|
||||
.withSealedModifier()
|
||||
.withoutAnnotationOf(Immutable::class, Stable::class)
|
||||
.map { it.fullyQualifiedName }
|
||||
Konsist.scopeFromProject()
|
||||
.functions()
|
||||
.withAnnotationOf(Composable::class)
|
||||
.assertTrue(additionalMessage = "Consider adding the @Immutable or @Stable annotation to the sealed interface") {
|
||||
val result = it.parameters.all { param ->
|
||||
val type = param.type.text
|
||||
return@all if (type.startsWith("@") || type.contains("->") || type.startsWith("suspend")) {
|
||||
true
|
||||
} else {
|
||||
val typePackage = param.type.sourceDeclaration?.let { declaration ->
|
||||
declaration.asTypeParameterDeclaration()?.packagee
|
||||
?: declaration.asExternalDeclaration()?.packagee
|
||||
?: declaration.asClassOrInterfaceDeclaration()?.packagee
|
||||
?: declaration.asKotlinTypeDeclaration()?.packagee
|
||||
?: declaration.asObjectDeclaration()?.packagee
|
||||
}?.name
|
||||
if (typePackage == null) {
|
||||
false
|
||||
} else {
|
||||
val fullyQualifiedName = "$typePackage.$type"
|
||||
fullyQualifiedName !in forbiddenInterfacesForComposableParameter
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!result && !failingTestFound && it.name == "FailingComposableWithNonImmutableSealedInterface") {
|
||||
failingTestFound = true
|
||||
true
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
assertTrue("FailingComposableWithNonImmutableSealedInterface should make this test fail.", failingTestFound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.verify.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistCallbackTest {
|
||||
@Test
|
||||
fun `we should not invoke Callback Input directly, we should use forEach`() {
|
||||
Konsist
|
||||
.scopeFromProduction()
|
||||
.files
|
||||
.assertFalse {
|
||||
it.text.contains("callback?.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.withAllParentsOf
|
||||
import com.lemonappdev.konsist.api.ext.list.withAnnotationNamed
|
||||
import com.lemonappdev.konsist.api.ext.list.withNameContaining
|
||||
import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
|
||||
import com.lemonappdev.konsist.api.ext.list.withPackage
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutName
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutNameStartingWith
|
||||
import com.lemonappdev.konsist.api.verify.assertEmpty
|
||||
import com.lemonappdev.konsist.api.verify.assertTrue
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistClassNameTest {
|
||||
@Test
|
||||
fun `Classes extending 'Presenter' should have 'Presenter' suffix`() {
|
||||
Konsist.scopeFromProject()
|
||||
.classes()
|
||||
.withAllParentsOf(Presenter::class)
|
||||
.assertTrue {
|
||||
it.name.endsWith("Presenter")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Classes extending 'Node' should have 'Node' suffix`() {
|
||||
Konsist.scopeFromProject()
|
||||
.classes()
|
||||
.withAllParentsOf(Node::class)
|
||||
.assertTrue {
|
||||
it.name.endsWith("Node")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Classes extending 'BaseFlowNode' should have 'FlowNode' suffix`() {
|
||||
Konsist.scopeFromProject()
|
||||
.classes()
|
||||
.withAllParentsOf(BaseFlowNode::class)
|
||||
.assertTrue {
|
||||
it.name.endsWith("FlowNode")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Classes extending 'PreviewParameterProvider' name MUST end with 'Provider' and MUST contain provided class name`() {
|
||||
Konsist.scopeFromProduction()
|
||||
.classes()
|
||||
.withAllParentsOf(PreviewParameterProvider::class)
|
||||
.withoutName(
|
||||
"AspectRatioProvider",
|
||||
"EditableAvatarViewUriProvider",
|
||||
"LoginModeViewErrorProvider",
|
||||
"OverlapRatioProvider",
|
||||
"TextFileContentProvider",
|
||||
)
|
||||
.also {
|
||||
// Check that classes are actually found
|
||||
assertThat(it.size).isGreaterThan(100)
|
||||
}
|
||||
.assertTrue { klass ->
|
||||
// Cannot find a better way to get the type of the generic
|
||||
val providedType = klass.text
|
||||
.substringAfter("PreviewParameterProvider<")
|
||||
.substringBefore(">")
|
||||
// Get the substring before the first '<' to remove the generic type
|
||||
.substringBefore("<")
|
||||
.removeSuffix("?")
|
||||
.replace(".", "")
|
||||
val name = klass.name
|
||||
name.endsWith("Provider") &&
|
||||
name.endsWith("PreviewProvider").not() &&
|
||||
name.contains(providedType)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Fake classes must be named using Fake and the interface it fakes`() {
|
||||
var failingCases = 0
|
||||
val failingCasesList = listOf(
|
||||
"FakeWrongClassName",
|
||||
"FakeWrongClassSubInterfaceName",
|
||||
)
|
||||
Konsist.scopeFromProject()
|
||||
.classes()
|
||||
.withNameContaining("Fake")
|
||||
.withoutName(
|
||||
"FakeFileSystem",
|
||||
"FakeImageLoader",
|
||||
"FakeListenableFuture",
|
||||
)
|
||||
.assertTrue {
|
||||
val interfaceName = it.name
|
||||
.replace("FakeFfi", "")
|
||||
.replace("Fake", "")
|
||||
val result = it.name.startsWith("Fake") &&
|
||||
it.parents().any { parent ->
|
||||
val parentName = parent.name.replace(".", "")
|
||||
parentName == interfaceName
|
||||
}
|
||||
if (!result && it.name in failingCasesList) {
|
||||
failingCases++
|
||||
true
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
assertThat(failingCases).isEqualTo(failingCasesList.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `All Classes that override a class from the Ffi layer must have 'FakeFfi' prefix`() {
|
||||
Konsist.scopeFromTest()
|
||||
.classes()
|
||||
.withPackage("io.element.android.libraries.matrix.impl.fixtures.fakes")
|
||||
.assertTrue { klass ->
|
||||
val parentName = klass.parents().firstOrNull()?.name.orEmpty()
|
||||
klass.name == "FakeFfi$parentName"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Class implementing interface should have name not end with 'Impl' but start with 'Default'`() {
|
||||
Konsist.scopeFromProject()
|
||||
.classes()
|
||||
.withNameEndingWith("Impl")
|
||||
.withoutName("MediaUploadHandlerImpl")
|
||||
.assertEmpty(additionalMessage = "Class implementing interface should have name not end with 'Impl' but start with 'Default'")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Class with 'ContributeBinding' annotation should have allowed prefix`() {
|
||||
Konsist.scopeFromProject()
|
||||
.classes()
|
||||
.withAnnotationNamed("ContributesBinding")
|
||||
.withoutName(
|
||||
"Factory",
|
||||
"TimelineController",
|
||||
"TimelineMediaGalleryDataSource",
|
||||
"MetroWorkerFactory",
|
||||
)
|
||||
.withoutNameStartingWith(
|
||||
"Accompanist",
|
||||
"AES",
|
||||
"Android",
|
||||
"Asset",
|
||||
"Database",
|
||||
"DBov",
|
||||
"Default",
|
||||
"DataStore",
|
||||
"Enterprise",
|
||||
"Fdroid",
|
||||
"FileExtensionExtractor",
|
||||
"Internal",
|
||||
"LiveMediaTimeline",
|
||||
"KeyStore",
|
||||
"Matrix",
|
||||
"Noop",
|
||||
"Oss",
|
||||
"Preferences",
|
||||
"Rust",
|
||||
"SharedPreferences",
|
||||
)
|
||||
.assertEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.lemonappdev.konsist.api.KoModifier
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.modifierprovider.withoutModifier
|
||||
import com.lemonappdev.konsist.api.ext.list.withAllAnnotationsOf
|
||||
import com.lemonappdev.konsist.api.ext.list.withTopLevel
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutName
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutNameEndingWith
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutReceiverType
|
||||
import com.lemonappdev.konsist.api.verify.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistComposableTest {
|
||||
@Test
|
||||
fun `Top level function with '@Composable' annotation starting with a upper case should be placed in a file with the same name`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withTopLevel()
|
||||
.withoutModifier(KoModifier.PRIVATE)
|
||||
.withoutNameEndingWith("Preview")
|
||||
.withAllAnnotationsOf(Composable::class)
|
||||
.withoutReceiverType()
|
||||
.withoutName(
|
||||
// Add some exceptions...
|
||||
"InvisibleButton",
|
||||
"OutlinedButton",
|
||||
"SimpleAlertDialogContent",
|
||||
"TextButton",
|
||||
"AvatarColorsPreviewLight",
|
||||
"AvatarColorsPreviewDark",
|
||||
"IconsCompoundPreviewLight",
|
||||
"IconsCompoundPreviewRtl",
|
||||
"IconsCompoundPreviewDark",
|
||||
"CompoundSemanticColorsLight",
|
||||
"CompoundSemanticColorsLightHc",
|
||||
"CompoundSemanticColorsDark",
|
||||
"CompoundSemanticColorsDarkHc",
|
||||
)
|
||||
.assertTrue(
|
||||
additionalMessage =
|
||||
"""
|
||||
Please check the filename. It should match the top level Composable function. If the filename is correct:
|
||||
- consider making the Composable private or moving it to its own file
|
||||
- at last resort, you can add an exception in the Konsist test
|
||||
""".trimIndent()
|
||||
) {
|
||||
if (it.name.first().isLowerCase()) {
|
||||
true
|
||||
} else {
|
||||
val fileName = it.containingFile.name.removeSuffix(".kt")
|
||||
fileName == it.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistConfigTest {
|
||||
@Test
|
||||
fun `assert that Konsist detect all the project classes`() {
|
||||
assertThat(
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.classes()
|
||||
.size
|
||||
)
|
||||
.isGreaterThan(1_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `assert that Konsist detect all the test classes`() {
|
||||
assertThat(
|
||||
Konsist
|
||||
.scopeFromTest()
|
||||
.classes()
|
||||
.size
|
||||
)
|
||||
.isGreaterThan(100)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.withImportNamed
|
||||
import com.lemonappdev.konsist.api.verify.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistContentTest {
|
||||
@Test
|
||||
fun `assert that BuildConfig dot VersionCode is not used`() {
|
||||
Konsist
|
||||
.scopeFromProduction()
|
||||
.files
|
||||
.withImportNamed("io.element.android.x.BuildConfig")
|
||||
.assertFalse(additionalMessage = "Please do not use BuildConfig.VERSION_CODE, but use the versionCode from BuildMeta") {
|
||||
it.text.contains("BuildConfig.VERSION_CODE")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
|
||||
import com.lemonappdev.konsist.api.ext.list.withParameter
|
||||
import com.lemonappdev.konsist.api.verify.assertFalse
|
||||
import com.lemonappdev.konsist.api.verify.assertTrue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistDiTest {
|
||||
@Test
|
||||
fun `class annotated with @Inject should not have constructors with @Assisted parameter`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.classes()
|
||||
.withAnnotationOf(Inject::class)
|
||||
.assertTrue(
|
||||
additionalMessage = "Class with @Assisted parameter in constructor should be annotated with @AssistedInject and not @Inject"
|
||||
) { classDeclaration ->
|
||||
classDeclaration.constructors
|
||||
.withParameter { parameterDeclaration ->
|
||||
parameterDeclaration.hasAnnotationOf(Assisted::class)
|
||||
}
|
||||
.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `class annotated with @ContributesBinding does not need to be annotated with @Inject anymore`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.classes()
|
||||
.withAnnotationOf(ContributesBinding::class)
|
||||
.assertFalse { classDeclaration ->
|
||||
classDeclaration.hasAnnotationOf(Inject::class)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.properties
|
||||
import com.lemonappdev.konsist.api.verify.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistFieldTest {
|
||||
@Test
|
||||
fun `no field should have 'm' prefix`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.classes()
|
||||
.properties()
|
||||
.assertFalse {
|
||||
val secondCharacterIsUppercase = it.name.getOrNull(1)?.isUpperCase() ?: false
|
||||
it.name.startsWith('m') && secondCharacterIsUppercase
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
|
||||
import com.lemonappdev.konsist.api.verify.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistFlowTest {
|
||||
@Test
|
||||
fun `flow must be remembered when it is collected as state`() {
|
||||
// Match
|
||||
// ```).collectAsState```
|
||||
// and
|
||||
// ```)
|
||||
// .collectAsState```
|
||||
val regex = "(.*)\\)(\n\\s*)*\\.collectAsState".toRegex()
|
||||
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withAnnotationOf(Composable::class)
|
||||
.assertFalse(
|
||||
additionalMessage = "Please check that the flow is remembered when it is collected as state." +
|
||||
" Only val flows can be not remembered.",
|
||||
) { function ->
|
||||
regex.matches(function.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
|
||||
import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutName
|
||||
import com.lemonappdev.konsist.api.verify.assertEmpty
|
||||
import com.lemonappdev.konsist.api.verify.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistImmutableTest {
|
||||
/**
|
||||
* toPersistentList() returns a PersistentList which allow mutations, while toImmutableList() returns
|
||||
* an ImmutableList which does not allow mutations. Generally, we do not use the mutation features,
|
||||
* so we should prefer toImmutableList.
|
||||
*/
|
||||
@Test
|
||||
fun `toPersistentList() should not be used instead of toImmutableList()`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withoutName("toPersistentList() should not be used instead of toImmutableList()")
|
||||
.assertFalse(additionalMessage = "Please use toImmutableList() instead of toPersistentList()") {
|
||||
it.text.contains(".toPersistentList()")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* toPersistentSet() returns a PersistentSet which allow mutations, while toImmutableSet() returns
|
||||
* an ImmutableSet which does not allow mutations. Generally, we do not use the mutation features,
|
||||
* so we should prefer toImmutableSet.
|
||||
*/
|
||||
@Test
|
||||
fun `toPersistentSet() should not be used instead of toImmutableSet()`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withoutName("toPersistentSet() should not be used instead of toImmutableSet()")
|
||||
.assertFalse(additionalMessage = "Please use toImmutableSet() instead of toPersistentSet()") {
|
||||
it.text.contains(".toPersistentSet()")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* toPersistentMap() returns a PersistentMap which allow mutations, while toImmutableMap() returns
|
||||
* an ImmutableMap which does not allow mutations. Generally, we do not use the mutation features,
|
||||
* so we should prefer toImmutableMap.
|
||||
*/
|
||||
@Test
|
||||
fun `toPersistentMap() should not be used instead of toImmutableMap()`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withoutName("toPersistentMap() should not be used instead of toImmutableMap()")
|
||||
.assertFalse(additionalMessage = "Please use toImmutableMap() instead of toPersistentMap()") {
|
||||
it.text.contains(".toPersistentMap()")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Immutable annotation is not used on sealed interface for Presenter Events`() {
|
||||
Konsist
|
||||
.scopeFromProduction()
|
||||
.interfaces()
|
||||
.withNameEndingWith("Events")
|
||||
.withAnnotationOf(Immutable::class)
|
||||
.assertEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.verify.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistImportTest {
|
||||
@Test
|
||||
fun `Functions with '@VisibleForTesting' annotation should use 'androidx' version`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.imports
|
||||
.assertFalse(
|
||||
additionalMessage = "Please use 'androidx.annotation.VisibleForTesting' instead of " +
|
||||
"'org.jetbrains.annotations.VisibleForTesting' (project convention).",
|
||||
) {
|
||||
it.name == "org.jetbrains.annotations.VisibleForTesting"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OutlinedTextField should not be used`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.imports
|
||||
.assertFalse(
|
||||
additionalMessage = "Please use 'io.element.android.libraries.designsystem.theme.components.TextField' instead of " +
|
||||
"'androidx.compose.material3.OutlinedTextField.",
|
||||
) {
|
||||
it.name == "androidx.compose.material3.OutlinedTextField"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `material3 TopAppBar should not be used`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.imports
|
||||
.assertFalse(
|
||||
additionalMessage = "Please use 'io.element.android.libraries.designsystem.theme.components.TopAppBar' instead of " +
|
||||
"'androidx.compose.material3.TopAppBar.",
|
||||
) {
|
||||
it.name == "androidx.compose.material3.TopAppBar"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.verify.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistLicenseTest {
|
||||
private val publicLicense = """
|
||||
/\*
|
||||
(?:.*\n)* \* Copyright \(c\) 20\d\d((, |-)20\d\d)? Element Creations Ltd\.
|
||||
(?:.*\n)* \*
|
||||
\* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial\.
|
||||
\* Please see LICENSE files in the repository root for full details\.
|
||||
\*/
|
||||
""".trimIndent().toRegex()
|
||||
|
||||
private val enterpriseLicense = """
|
||||
/\*
|
||||
\* © 20\d\d((, |-)20\d\d)? Element Creations Ltd\.
|
||||
(?:.*\n)* \*
|
||||
\* Element Creations Ltd, Element Software SARL, Element Software Inc\.,
|
||||
\* and Element Software GmbH \(the "Element Group"\) only make this file available
|
||||
\* under a proprietary license model\.
|
||||
\*
|
||||
\* Without a proprietary license with us, you cannot use this file\. The terms of
|
||||
\* the proprietary license agreement between you and any member of the Element Group
|
||||
\* shall always apply to your use of this file\. Unauthorised use, copying, distribution,
|
||||
\* or modification of this file, via any medium, is strictly prohibited\.
|
||||
\*
|
||||
\* For details about the licensing terms, you must either visit our website or contact
|
||||
\* a member of our sales team\.
|
||||
\*/
|
||||
""".trimIndent().toRegex()
|
||||
|
||||
@Test
|
||||
fun `assert that FOSS files have the correct license header`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.files
|
||||
.filter {
|
||||
it.moduleName.startsWith("enterprise").not() &&
|
||||
it.nameWithExtension != "locales.kt" &&
|
||||
it.name.startsWith("Template ").not()
|
||||
}
|
||||
.also {
|
||||
assertThat(it).isNotEmpty()
|
||||
}
|
||||
.assertTrue {
|
||||
publicLicense.containsMatchIn(it.text)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `assert that Enterprise files have the correct license header`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.files
|
||||
.filter {
|
||||
it.moduleName.startsWith("enterprise")
|
||||
}
|
||||
.assertTrue {
|
||||
enterpriseLicense.containsMatchIn(it.text)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `assert that files do not have double license header`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.files
|
||||
.filter {
|
||||
it.nameWithExtension != "locales.kt" &&
|
||||
it.nameWithExtension != "KonsistLicenseTest.kt" &&
|
||||
it.name.startsWith("Template ").not()
|
||||
}
|
||||
.assertTrue {
|
||||
it.text.count("Element Creations Ltd.") == 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.count(subString: String): Int {
|
||||
var count = 0
|
||||
var index = 0
|
||||
while (true) {
|
||||
index = indexOf(subString, index)
|
||||
if (index == -1) return count
|
||||
count++
|
||||
index += subString.length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.verify.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistMethodNameTest {
|
||||
@Test
|
||||
fun `Ensure that method name does not start or end with spaces`() {
|
||||
Konsist.scopeFromProject()
|
||||
.functions()
|
||||
.assertTrue {
|
||||
it.name.trim() == it.name
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.withParameter
|
||||
import com.lemonappdev.konsist.api.verify.assertEmpty
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistParameterNameTest {
|
||||
@Test
|
||||
fun `Function parameter should not end with 'Press' but with 'Click'`() {
|
||||
Konsist.scopeFromProject()
|
||||
.functions()
|
||||
.withParameter { parameter ->
|
||||
parameter.name.endsWith("Press")
|
||||
}
|
||||
.assertEmpty(additionalMessage = "Please rename the parameter, for instance from 'onBackPress' to 'onBackClick'.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.constructors
|
||||
import com.lemonappdev.konsist.api.ext.list.withAllParentsOf
|
||||
import com.lemonappdev.konsist.api.verify.assertTrue
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistPresenterTest {
|
||||
@Test
|
||||
fun `'Presenter' should not depend on other presenters`() {
|
||||
Konsist.scopeFromProject()
|
||||
.classes()
|
||||
.withAllParentsOf(Presenter::class)
|
||||
.constructors
|
||||
.assertTrue { constructor ->
|
||||
val result = constructor.parameters.none { parameter ->
|
||||
parameter.type.name.endsWith("Presenter")
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.withAllAnnotationsOf
|
||||
import com.lemonappdev.konsist.api.ext.list.withName
|
||||
import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutName
|
||||
import com.lemonappdev.konsist.api.verify.assertEmpty
|
||||
import com.lemonappdev.konsist.api.verify.assertTrue
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistPreviewTest {
|
||||
@Test
|
||||
fun `Functions with '@PreviewsDayNight' annotation should have 'Preview' suffix`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withAllAnnotationsOf(PreviewsDayNight::class)
|
||||
.assertTrue {
|
||||
it.hasNameEndingWith("Preview") &&
|
||||
it.hasNameEndingWith("LightPreview").not() &&
|
||||
it.hasNameEndingWith("DarkPreview").not()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Check functions with 'A11yPreview'`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withNameEndingWith("A11yPreview")
|
||||
.assertTrue(
|
||||
additionalMessage = "Functions with 'A11yPreview' suffix should have '@Preview' annotation and not '@PreviewsDayNight'," +
|
||||
" should contain 'ElementPreview' composable," +
|
||||
" should contain the tested view" +
|
||||
" and should be internal."
|
||||
) {
|
||||
val testedView = it.name.removeSuffix("A11yPreview")
|
||||
it.text.contains("$testedView(") &&
|
||||
it.hasAllAnnotationsOf(PreviewsDayNight::class).not() &&
|
||||
it.text.contains("ElementPreview") &&
|
||||
it.hasInternalModifier
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Functions with '@PreviewsDayNight' annotation should contain 'ElementPreview' composable`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withAllAnnotationsOf(PreviewsDayNight::class)
|
||||
.assertTrue {
|
||||
it.text.contains("ElementPreview")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Functions with '@PreviewsDayNight' are internal`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withAllAnnotationsOf(PreviewsDayNight::class)
|
||||
.assertTrue {
|
||||
it.hasInternalModifier
|
||||
}
|
||||
}
|
||||
|
||||
private val previewNameExceptions = listOf(
|
||||
"AsyncIndicatorFailurePreview",
|
||||
"AsyncIndicatorLoadingPreview",
|
||||
"BackgroundVerticalGradientDisabledPreview",
|
||||
"BackgroundVerticalGradientPreview",
|
||||
"ColorAliasesPreview",
|
||||
"FocusedEventPreview",
|
||||
"GradientFloatingActionButtonCircleShapePreview",
|
||||
"HeaderFooterPageScrollablePreview",
|
||||
"HomeTopBarMultiAccountPreview",
|
||||
"HomeTopBarWithIndicatorPreview",
|
||||
"IconsOtherPreview",
|
||||
"MarkdownTextComposerEditPreview",
|
||||
"MatrixBadgeAtomInfoPreview",
|
||||
"MatrixBadgeAtomNegativePreview",
|
||||
"MatrixBadgeAtomNeutralPreview",
|
||||
"MatrixBadgeAtomPositivePreview",
|
||||
"MentionSpanThemeInTimelinePreview",
|
||||
"MessageComposerViewVoicePreview",
|
||||
"MessagesReactionButtonAddPreview",
|
||||
"MessagesReactionButtonExtraPreview",
|
||||
"MessagesViewWithIdentityChangePreview",
|
||||
"PendingMemberRowWithLongNamePreview",
|
||||
"PinUnlockViewInAppPreview",
|
||||
"PollAnswerViewDisclosedNotSelectedPreview",
|
||||
"PollAnswerViewDisclosedSelectedPreview",
|
||||
"PollAnswerViewEndedSelectedPreview",
|
||||
"PollAnswerViewEndedWinnerNotSelectedPreview",
|
||||
"PollAnswerViewEndedWinnerSelectedPreview",
|
||||
"PollAnswerViewUndisclosedNotSelectedPreview",
|
||||
"PollAnswerViewUndisclosedSelectedPreview",
|
||||
"PollContentViewCreatorEditablePreview",
|
||||
"PollContentViewCreatorEndedPreview",
|
||||
"PollContentViewCreatorPreview",
|
||||
"PollContentViewDisclosedPreview",
|
||||
"PollContentViewEndedPreview",
|
||||
"PollContentViewUndisclosedPreview",
|
||||
"ProgressDialogWithContentPreview",
|
||||
"ProgressDialogWithTextAndContentPreview",
|
||||
"ReadReceiptBottomSheetPreview",
|
||||
"SasEmojisPreview",
|
||||
"SecureBackupSetupViewChangePreview",
|
||||
"SelectedUserCannotRemovePreview",
|
||||
"SpaceMembersViewNoHeroesPreview",
|
||||
"TextComposerAddCaptionPreview",
|
||||
"TextComposerCaptionPreview",
|
||||
"TextComposerEditCaptionPreview",
|
||||
"TextComposerEditNotEncryptedPreview",
|
||||
"TextComposerEditPreview",
|
||||
"TextComposerFormattingNotEncryptedPreview",
|
||||
"TextComposerFormattingPreview",
|
||||
"TextComposerLinkDialogCreateLinkPreview",
|
||||
"TextComposerLinkDialogCreateLinkWithoutTextPreview",
|
||||
"TextComposerLinkDialogEditLinkPreview",
|
||||
"TextComposerReplyPreview",
|
||||
"TextComposerSimpleNotEncryptedPreview",
|
||||
"TextComposerSimplePreview",
|
||||
"TextComposerVoiceNotEncryptedPreview",
|
||||
"TextComposerVoicePreview",
|
||||
"TextFieldDialogWithErrorPreview",
|
||||
"TimelineImageWithCaptionRowPreview",
|
||||
"TimelineItemEventRowForDirectRoomPreview",
|
||||
"TimelineItemEventRowShieldPreview",
|
||||
"TimelineItemEventRowTimestampPreview",
|
||||
"TimelineItemEventRowUtdPreview",
|
||||
"TimelineItemEventRowWithManyReactionsPreview",
|
||||
"TimelineItemEventRowWithRRPreview",
|
||||
"TimelineItemEventRowWithReplyPreview",
|
||||
"TimelineItemEventRowWithThreadSummaryPreview",
|
||||
"TimelineItemGroupedEventsRowContentCollapsePreview",
|
||||
"TimelineItemGroupedEventsRowContentExpandedPreview",
|
||||
"TimelineItemImageViewHideMediaContentPreview",
|
||||
"TimelineItemVideoViewHideMediaContentPreview",
|
||||
"TimelineItemVoiceViewUnifiedPreview",
|
||||
"TimelineVideoWithCaptionRowPreview",
|
||||
"TimelineViewMessageShieldPreview",
|
||||
"UserAvatarColorsPreview",
|
||||
"UserProfileHeaderSectionWithVerificationViolationPreview",
|
||||
"VoiceItemViewPlayPreview",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `previewNameExceptions is sorted alphabetically`() {
|
||||
assertThat(previewNameExceptions.sorted()).isEqualTo(previewNameExceptions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `previewNameExceptions only contains existing functions`() {
|
||||
val names = previewNameExceptions.toMutableSet()
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withAllAnnotationsOf(PreviewsDayNight::class)
|
||||
.withName(previewNameExceptions)
|
||||
.let {
|
||||
it.forEach { function ->
|
||||
names.remove(function.name)
|
||||
}
|
||||
}
|
||||
assertThat(names).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Functions with '@PreviewsDayNight' have correct name`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withAllAnnotationsOf(PreviewsDayNight::class)
|
||||
.withoutName(previewNameExceptions)
|
||||
.assertTrue(
|
||||
additionalMessage = "Functions for Preview should be named like this: <ViewUnderPreview>Preview. " +
|
||||
"Exception can be added to the test, for multiple Previews of the same view",
|
||||
) {
|
||||
val testedView = if (it.name.endsWith("RtlPreview")) {
|
||||
it.name.removeSuffix("RtlPreview")
|
||||
} else {
|
||||
it.name.removeSuffix("Preview")
|
||||
}
|
||||
it.name.endsWith("Preview") &&
|
||||
(it.text.contains("$testedView(") ||
|
||||
it.text.contains("$testedView {") ||
|
||||
it.text.contains("ContentToPreview("))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Ensure that '@PreviewLightDark' is not used`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withAllAnnotationsOf(PreviewLightDark::class)
|
||||
.assertEmpty(
|
||||
additionalMessage = "Use '@PreviewsDayNight' instead of '@PreviewLightDark', or else screenshot(s) will not be generated.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.konsist
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.lemonappdev.konsist.api.Konsist
|
||||
import com.lemonappdev.konsist.api.ext.list.modifierprovider.withoutOverrideModifier
|
||||
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
|
||||
import com.lemonappdev.konsist.api.ext.list.withFunction
|
||||
import com.lemonappdev.konsist.api.ext.list.withReturnType
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutAnnotationOf
|
||||
import com.lemonappdev.konsist.api.ext.list.withoutName
|
||||
import com.lemonappdev.konsist.api.verify.assertFalse
|
||||
import com.lemonappdev.konsist.api.verify.assertTrue
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
|
||||
class KonsistTestTest {
|
||||
@Test
|
||||
fun `Ensure that unit tests are detected`() {
|
||||
val numberOfTests = Konsist
|
||||
.scopeFromTest()
|
||||
.functions()
|
||||
.withAnnotationOf(Test::class)
|
||||
.withoutAnnotationOf(Ignore::class)
|
||||
.size
|
||||
println("Number of unit tests: $numberOfTests")
|
||||
assertThat(numberOfTests).isGreaterThan(2000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Classes name containing @Test must end with 'Test'`() {
|
||||
Konsist
|
||||
.scopeFromTest()
|
||||
.classes()
|
||||
.withoutName("S", "T")
|
||||
.withFunction { it.hasAnnotationOf(Test::class) }
|
||||
.assertTrue { it.name.endsWith("Test") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Function which creates Presenter in test MUST be named 'createPresenterName'`() {
|
||||
Konsist
|
||||
.scopeFromTest()
|
||||
.functions()
|
||||
.withReturnType { it.name.endsWith("Presenter") }
|
||||
.withoutOverrideModifier()
|
||||
.assertTrue(
|
||||
additionalMessage = "The function can also be named 'createPresenter'. To please Konsist in this case, just remove the return type."
|
||||
) { functionDeclaration ->
|
||||
functionDeclaration.name == "create${functionDeclaration.returnType?.name}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `assertion methods must be imported`() {
|
||||
Konsist
|
||||
.scopeFromTest()
|
||||
.functions()
|
||||
// Exclude self
|
||||
.withoutName("assertion methods must be imported")
|
||||
.assertFalse(
|
||||
additionalMessage = "Import methods from Truth, instead of using for instance Truth.assertThat(...)"
|
||||
) { functionDeclaration ->
|
||||
functionDeclaration.text.contains("Truth.")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `use isFalse() instead of isEqualTo(false)`() {
|
||||
Konsist
|
||||
.scopeFromTest()
|
||||
.functions()
|
||||
// Exclude self
|
||||
.withoutName("use isFalse() instead of isEqualTo(false)")
|
||||
.assertFalse { functionDeclaration ->
|
||||
functionDeclaration.text.contains("isEqualTo(false)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `use isTrue() instead of isEqualTo(true)`() {
|
||||
Konsist
|
||||
.scopeFromTest()
|
||||
.functions()
|
||||
// Exclude self
|
||||
.withoutName("use isTrue() instead of isEqualTo(true)")
|
||||
.assertFalse { functionDeclaration ->
|
||||
functionDeclaration.text.contains("isEqualTo(true)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `use isEmpty() instead of isEqualTo(empty)`() {
|
||||
Konsist
|
||||
.scopeFromTest()
|
||||
.functions()
|
||||
// Exclude self
|
||||
.withoutName("use isEmpty() instead of isEqualTo(empty)")
|
||||
.assertFalse { functionDeclaration ->
|
||||
functionDeclaration.text.contains("isEqualTo(empty")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `use isNull() instead of isEqualTo(null)`() {
|
||||
Konsist
|
||||
.scopeFromTest()
|
||||
.functions()
|
||||
// Exclude self
|
||||
.withoutName("use isNull() instead of isEqualTo(null)")
|
||||
.assertFalse { functionDeclaration ->
|
||||
functionDeclaration.text.contains("isEqualTo(null)")
|
||||
}
|
||||
}
|
||||
}
|
||||
33
tests/testutils/build.gradle.kts
Normal file
33
tests/testutils/build.gradle.kts
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.tests.testutils"
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.test.junit)
|
||||
implementation(libs.test.truth)
|
||||
implementation(libs.coroutines.test)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.services.toolbox.api)
|
||||
implementation(libs.test.turbine)
|
||||
implementation(libs.molecule.runtime)
|
||||
implementation(libs.androidx.compose.ui.test.junit)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import io.element.android.libraries.androidutils.metadata.isInDebug
|
||||
import org.junit.Assert.assertThrows
|
||||
|
||||
/**
|
||||
* Assert that the lambda throws only on debug mode.
|
||||
*/
|
||||
fun assertThrowsInDebug(lambda: () -> Any?) {
|
||||
if (isInDebug) {
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
lambda()
|
||||
}
|
||||
} else {
|
||||
lambda()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
class EnsureCalledOnce : () -> Unit {
|
||||
private var counter = 0
|
||||
override fun invoke() {
|
||||
counter++
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != 1) {
|
||||
throw AssertionError("Expected to be called once, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureCalledTimes(val times: Int) : () -> Unit {
|
||||
private var counter = 0
|
||||
override fun invoke() {
|
||||
counter++
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != times) {
|
||||
throw AssertionError("Expected to be called $times, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureCalledOnce(block: (callback: () -> Unit) -> Unit) {
|
||||
val callback = EnsureCalledOnce()
|
||||
block(callback)
|
||||
callback.assertSuccess()
|
||||
}
|
||||
|
||||
fun ensureCalledTimes(times: Int, block: (callback: () -> Unit) -> Unit) {
|
||||
val callback = EnsureCalledTimes(times)
|
||||
block(callback)
|
||||
callback.assertSuccess()
|
||||
}
|
||||
|
||||
class EnsureCalledOnceWithParam<T, R>(
|
||||
private val expectedParam: T,
|
||||
private val result: R,
|
||||
) : (T) -> R {
|
||||
private var counter = 0
|
||||
override fun invoke(p1: T): R {
|
||||
if (p1 != expectedParam) {
|
||||
throw AssertionError("Expected to be called with $expectedParam, but was called with $p1")
|
||||
}
|
||||
counter++
|
||||
return result
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != 1) {
|
||||
throw AssertionError("Expected to be called once, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureCalledOnceWithTwoParams<T, U>(
|
||||
private val expectedParam1: T,
|
||||
private val expectedParam2: U,
|
||||
) : (T, U) -> Unit {
|
||||
private var counter = 0
|
||||
override fun invoke(p1: T, p2: U) {
|
||||
if (p1 != expectedParam1 || p2 != expectedParam2) {
|
||||
throw AssertionError("Expected to be called with $expectedParam1 and $expectedParam2, but was called with $p1 and $p2")
|
||||
}
|
||||
counter++
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != 1) {
|
||||
throw AssertionError("Expected to be called once, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureCalledOnceWithTwoParamsAndResult<T, U, R>(
|
||||
private val expectedParam1: T,
|
||||
private val expectedParam2: U,
|
||||
private val result: R,
|
||||
) : (T, U) -> R {
|
||||
private var counter = 0
|
||||
override fun invoke(p1: T, p2: U): R {
|
||||
if (p1 != expectedParam1 || p2 != expectedParam2) {
|
||||
throw AssertionError("Expected to be called with $expectedParam1 and $expectedParam2, but was called with $p1 and $p2")
|
||||
}
|
||||
counter++
|
||||
return result
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != 1) {
|
||||
throw AssertionError("Expected to be called once, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut for [<T, R> ensureCalledOnceWithParam] with Unit result.
|
||||
*/
|
||||
fun <T> ensureCalledOnceWithParam(param: T, block: (callback: EnsureCalledOnceWithParam<T, Unit>) -> Unit) {
|
||||
ensureCalledOnceWithParam(param, block, Unit)
|
||||
}
|
||||
|
||||
fun <T, R> ensureCalledOnceWithParam(param: T, block: (callback: EnsureCalledOnceWithParam<T, R>) -> R, result: R) {
|
||||
val callback = EnsureCalledOnceWithParam(param, result)
|
||||
block(callback)
|
||||
callback.assertSuccess()
|
||||
}
|
||||
|
||||
fun <P1, P2> ensureCalledOnceWithTwoParams(param1: P1, param2: P2, block: (callback: EnsureCalledOnceWithTwoParams<P1, P2>) -> Unit) {
|
||||
val callback = EnsureCalledOnceWithTwoParams(param1, param2)
|
||||
block(callback)
|
||||
callback.assertSuccess()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class EnsureNeverCalled : () -> Unit {
|
||||
override fun invoke() {
|
||||
lambdaError()
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureNeverCalledWithParam<T> : (T) -> Unit {
|
||||
override fun invoke(p1: T) {
|
||||
lambdaError("Should not be called and is called with $p1")
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureNeverCalledWithParamAndResult<T, R> : (T) -> R {
|
||||
override fun invoke(p1: T): R {
|
||||
lambdaError("Should not be called and is called with $p1")
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureNeverCalledWithTwoParams<T, U> : (T, U) -> Unit {
|
||||
override fun invoke(p1: T, p2: U) {
|
||||
lambdaError("Should not be called and is called with $p1 and $p2")
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureNeverCalledWithTwoParamsAndResult<T, U, R> : (T, U) -> R {
|
||||
override fun invoke(p1: T, p2: U): R {
|
||||
lambdaError("Should not be called and is called with $p1 and $p2")
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureNeverCalledWithThreeParams<T, U, V> : (T, U, V) -> Unit {
|
||||
override fun invoke(p1: T, p2: U, p3: V) {
|
||||
lambdaError("Should not be called and is called with $p1, $p2 and $p3")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
||||
class EventsRecorder<T>(
|
||||
private val expectEvents: Boolean = true
|
||||
) : (T) -> Unit {
|
||||
private val events = mutableListOf<T>()
|
||||
|
||||
override fun invoke(event: T) {
|
||||
if (expectEvents) {
|
||||
events.add(event)
|
||||
} else {
|
||||
throw AssertionError("Unexpected event: $event")
|
||||
}
|
||||
}
|
||||
|
||||
fun assertEmpty() {
|
||||
assertThat(events).isEmpty()
|
||||
}
|
||||
|
||||
fun assertSingle(event: T) {
|
||||
assertList(listOf(event))
|
||||
}
|
||||
|
||||
fun assertList(expectedEvents: List<T>) {
|
||||
assertThat(events).isEqualTo(expectedEvents)
|
||||
}
|
||||
|
||||
fun assertSize(size: Int) {
|
||||
assertThat(events.size).isEqualTo(size)
|
||||
}
|
||||
|
||||
fun assertTrue(index: Int, predicate: (T) -> Boolean) {
|
||||
assertThat(predicate(events[index])).isTrue()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
events.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
||||
class InstrumentationStringProvider : StringProvider {
|
||||
private val resource = InstrumentationRegistry.getInstrumentation().context.resources
|
||||
override fun getString(resId: Int): String {
|
||||
return resource.getString(resId)
|
||||
}
|
||||
|
||||
override fun getString(resId: Int, vararg formatArgs: Any?): String {
|
||||
return resource.getString(resId, *formatArgs)
|
||||
}
|
||||
|
||||
override fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any?): String {
|
||||
return resource.getQuantityString(resId, quantity, *formatArgs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Workaround for https://github.com/cashapp/molecule/issues/249.
|
||||
* This functions should be removed/deprecated right after we find a proper fix.
|
||||
*/
|
||||
suspend inline fun <T> simulateLongTask(lambda: () -> T): T {
|
||||
delay(1)
|
||||
return lambda()
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used for testing events in Presenter, where the event does not emit new state.
|
||||
* If the (virtual) timeout is passed, we release the latch manually.
|
||||
*/
|
||||
suspend fun awaitWithLatch(timeout: Duration = 300.milliseconds, block: (CompletableDeferred<Unit>) -> Unit) {
|
||||
val latch = CompletableDeferred<Unit>()
|
||||
try {
|
||||
withTimeout(timeout) {
|
||||
latch.also(block).await()
|
||||
}
|
||||
} catch (exception: TimeoutCancellationException) {
|
||||
latch.complete(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class MutablePresenter<State>(initialState: State) : Presenter<State> {
|
||||
private val stateFlow = MutableStateFlow(initialState)
|
||||
|
||||
fun updateState(state: State) {
|
||||
stateFlow.value = state
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): State {
|
||||
return stateFlow.collectAsState().value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import org.junit.Assert.fail
|
||||
import kotlin.time.Duration
|
||||
|
||||
suspend fun <State> Presenter<State>.test(
|
||||
timeout: Duration? = null,
|
||||
name: String? = null,
|
||||
validate: suspend TurbineTestContext<State>.() -> Unit,
|
||||
) {
|
||||
try {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
present()
|
||||
}.test(timeout, name, validate)
|
||||
} catch (t: Throwable) {
|
||||
if (t::class.simpleName == "KotlinReflectionInternalError") {
|
||||
// Give a more explicit error to the developer
|
||||
fail("""
|
||||
It looks like you have an unconsumed event in your test.
|
||||
If you get this error, it means that your test is missing to consume one or several events.
|
||||
You can fix by consuming and check the event with `awaitItem()`, or you can also invoke
|
||||
`cancelAndIgnoreRemainingEvents()`.
|
||||
""".trimIndent())
|
||||
}
|
||||
throw t
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import app.cash.turbine.Event
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.withTurbineTimeout
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Consume all items until timeout is reached waiting for an event or we receive terminal event.
|
||||
* The timeout is applied for each event.
|
||||
* @return the list of consumed items.
|
||||
*/
|
||||
suspend fun <T : Any> ReceiveTurbine<T>.consumeItemsUntilTimeout(timeout: Duration = 100.milliseconds): List<T> {
|
||||
return consumeItemsUntilPredicate(timeout, ignoreTimeoutError = true) { false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume all items which are emitted sequentially.
|
||||
* Use the smallest timeout possible internally to avoid wasting time.
|
||||
* Same as calling skipItems(x) and then awaitItem() but without assumption on the number of items.
|
||||
* @return the last item emitted.
|
||||
*/
|
||||
suspend fun <T : Any> ReceiveTurbine<T>.awaitLastSequentialItem(): T {
|
||||
return consumeItemsUntilTimeout(1.milliseconds).last()
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume items until predicate is true, or timeout is reached waiting for an event, or we receive terminal event.
|
||||
* The timeout is applied for each event.
|
||||
* @return the list of consumed items.
|
||||
*/
|
||||
suspend fun <T : Any> ReceiveTurbine<T>.consumeItemsUntilPredicate(
|
||||
timeout: Duration = 3.seconds,
|
||||
ignoreTimeoutError: Boolean = false,
|
||||
predicate: (T) -> Boolean,
|
||||
): List<T> {
|
||||
val items = ArrayList<T>()
|
||||
var exitLoop = false
|
||||
try {
|
||||
while (!exitLoop) {
|
||||
when (val event = withTurbineTimeout(timeout) { awaitEvent() }) {
|
||||
is Event.Item<T> -> {
|
||||
items.add(event.value)
|
||||
exitLoop = predicate(event.value)
|
||||
}
|
||||
Event.Complete -> error("Unexpected complete")
|
||||
is Event.Error -> throw event.throwable
|
||||
}
|
||||
}
|
||||
} catch (assertionError: AssertionError) {
|
||||
// TurbineAssertionError is internal :/, so rely on the message
|
||||
if (assertionError.message?.startsWith("No value produced in").orFalse() && ignoreTimeoutError) {
|
||||
// Timeout, ignore
|
||||
} else {
|
||||
throw assertionError
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.rules.TestRule
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
object RobolectricDispatcherCleaner {
|
||||
// HACK: Workaround for https://github.com/robolectric/robolectric/issues/7055#issuecomment-1551119229
|
||||
fun clearAndroidUiDispatcher(pkg: String = "androidx.compose.ui.platform") {
|
||||
val clazz = javaClass.classLoader!!.loadClass("$pkg.AndroidUiDispatcher")
|
||||
val combinedContextClass = javaClass.classLoader!!.loadClass("kotlin.coroutines.CombinedContext")
|
||||
val companionClazz = clazz.getDeclaredField("Companion").get(clazz)
|
||||
val combinedContext = companionClazz.javaClass.getDeclaredMethod("getMain")
|
||||
.invoke(companionClazz) as CoroutineContext
|
||||
val androidUiDispatcher = combinedContextClass.getDeclaredField("element")
|
||||
.apply { isAccessible = true }
|
||||
.get(combinedContext)
|
||||
.let { clazz.cast(it) }
|
||||
var scheduledFrameDispatch = clazz.getDeclaredField("scheduledFrameDispatch")
|
||||
.apply { isAccessible = true }
|
||||
.getBoolean(androidUiDispatcher)
|
||||
var scheduledTrampolineDispatch = clazz.getDeclaredField("scheduledTrampolineDispatch")
|
||||
.apply { isAccessible = true }
|
||||
.getBoolean(androidUiDispatcher)
|
||||
val dispatchCallback = clazz.getDeclaredField("dispatchCallback")
|
||||
.apply { isAccessible = true }
|
||||
.get(androidUiDispatcher) as Runnable
|
||||
if (scheduledFrameDispatch || scheduledTrampolineDispatch) {
|
||||
dispatchCallback.run()
|
||||
scheduledFrameDispatch = clazz.getDeclaredField("scheduledFrameDispatch")
|
||||
.apply { isAccessible = true }
|
||||
.getBoolean(androidUiDispatcher)
|
||||
scheduledTrampolineDispatch = clazz.getDeclaredField("scheduledTrampolineDispatch")
|
||||
.apply { isAccessible = true }
|
||||
.getBoolean(androidUiDispatcher)
|
||||
}
|
||||
assertFalse(scheduledFrameDispatch)
|
||||
assertFalse(scheduledTrampolineDispatch)
|
||||
}
|
||||
}
|
||||
|
||||
fun <R : TestRule, A : ComponentActivity> AndroidComposeTestRule<R, A>.setSafeContent(
|
||||
clearAndroidUiDispatcher: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (clearAndroidUiDispatcher) {
|
||||
RobolectricDispatcherCleaner.clearAndroidUiDispatcher()
|
||||
}
|
||||
setContent {
|
||||
CompositionLocalProvider(LocalUiTestMode provides true) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import org.junit.rules.TestRule
|
||||
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOn(@StringRes res: Int) {
|
||||
val text = activity.getString(res)
|
||||
onNode(hasText(text) and hasClickAction())
|
||||
.performClick()
|
||||
}
|
||||
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOnFirst(@StringRes res: Int) {
|
||||
val text = activity.getString(res)
|
||||
onAllNodes(hasText(text) and hasClickAction()).onFirst().performClick()
|
||||
}
|
||||
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOnLast(@StringRes res: Int) {
|
||||
val text = activity.getString(res)
|
||||
onAllNodes(hasText(text) and hasClickAction()).onFirst().performClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Press the back button in the app bar.
|
||||
*/
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBack() {
|
||||
val text = activity.getString(CommonStrings.action_back)
|
||||
onNode(hasContentDescription(text)).performClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Press the back key.
|
||||
*/
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBackKey() {
|
||||
activity.onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
fun SemanticsNodeInteractionsProvider.pressTag(tag: String) {
|
||||
onNode(hasTestTag(tag)).performClick()
|
||||
}
|
||||
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNoNodeWithText(@StringRes res: Int) {
|
||||
val text = activity.getString(res)
|
||||
onNodeWithText(text).assertDoesNotExist()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
|
||||
@Composable
|
||||
fun withConfigurationAndContext(content: @Composable () -> Any?): Any? {
|
||||
var result: Any? = null
|
||||
CompositionLocalProvider(
|
||||
LocalConfiguration provides Configuration(),
|
||||
LocalContext provides ApplicationProvider.getApplicationContext(),
|
||||
) {
|
||||
result = content()
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
|
||||
/**
|
||||
* Create a [CoroutineDispatchers] instance for testing.
|
||||
*
|
||||
* @param useUnconfinedTestDispatcher If true, use [UnconfinedTestDispatcher] for all dispatchers.
|
||||
* If false, use [StandardTestDispatcher] for all dispatchers.
|
||||
*/
|
||||
fun TestScope.testCoroutineDispatchers(
|
||||
useUnconfinedTestDispatcher: Boolean = false,
|
||||
): CoroutineDispatchers = when (useUnconfinedTestDispatcher) {
|
||||
true -> CoroutineDispatchers(
|
||||
io = UnconfinedTestDispatcher(testScheduler),
|
||||
computation = UnconfinedTestDispatcher(testScheduler),
|
||||
main = UnconfinedTestDispatcher(testScheduler),
|
||||
)
|
||||
false -> CoroutineDispatchers(
|
||||
io = StandardTestDispatcher(testScheduler),
|
||||
computation = StandardTestDispatcher(testScheduler),
|
||||
main = StandardTestDispatcher(testScheduler),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import timber.log.Timber
|
||||
|
||||
fun plantTestTimber() {
|
||||
Timber.plant(object : Timber.Tree() {
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
println("$tag: $message")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
suspend fun waitForPredicate(
|
||||
delayBetweenAttemptsMillis: Long = 1,
|
||||
maxNumberOfAttempts: Int = 20,
|
||||
predicate: () -> Boolean,
|
||||
) {
|
||||
for (i in 0..maxNumberOfAttempts) {
|
||||
if (predicate()) return
|
||||
if (i < maxNumberOfAttempts) delay(delayBetweenAttemptsMillis)
|
||||
}
|
||||
throw AssertionError("Predicate was not true after $maxNumberOfAttempts attempts")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runners.model.Statement
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* moleculeFlow can take time to initialise during the first test of any given
|
||||
* test class.
|
||||
*
|
||||
* Applying this test rule ensures that the slow initialisation is not done
|
||||
* inside runTest which has a short default timeout.
|
||||
*/
|
||||
class WarmUpRule : TestRule {
|
||||
companion object {
|
||||
init {
|
||||
warmUpMolecule()
|
||||
}
|
||||
}
|
||||
|
||||
override fun apply(base: Statement, description: Description): Statement = base
|
||||
}
|
||||
|
||||
private fun warmUpMolecule() {
|
||||
runTest(timeout = 60.seconds) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
// Do nothing
|
||||
}.test {
|
||||
awaitItem() // Await a Unit composition
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
||||
/**
|
||||
* Composable that provides a fake [LifecycleOwner] to the composition.
|
||||
*/
|
||||
@OptIn(InternalComposeApi::class)
|
||||
@Stable
|
||||
@Composable
|
||||
fun <T> withFakeLifecycleOwner(
|
||||
lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(),
|
||||
block: @Composable () -> T
|
||||
): T {
|
||||
currentComposer.startProvider(LocalLifecycleOwner provides lifecycleOwner)
|
||||
val state = block()
|
||||
currentComposer.endProvider()
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a [Presenter] with a fake [LifecycleOwner].
|
||||
*/
|
||||
suspend fun <T> Presenter<T>.testWithLifecycleOwner(
|
||||
lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(),
|
||||
block: suspend TurbineTestContext<T>.() -> Unit
|
||||
) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
withFakeLifecycleOwner(lifecycleOwner) {
|
||||
present()
|
||||
}
|
||||
}.test(validate = block)
|
||||
}
|
||||
|
||||
@SuppressLint("VisibleForTests")
|
||||
class FakeLifecycleOwner(initialState: Lifecycle.State? = null) : LifecycleOwner {
|
||||
override val lifecycle: Lifecycle = LifecycleRegistry.createUnsafe(this)
|
||||
|
||||
init {
|
||||
initialState?.let { givenState(it) }
|
||||
}
|
||||
|
||||
fun givenState(state: Lifecycle.State) {
|
||||
(lifecycle as LifecycleRegistry).currentState = state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.fake
|
||||
|
||||
import android.net.Uri
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeTemporaryUriDeleter(
|
||||
val deleteLambda: (uri: Uri?) -> Unit = { lambdaError() }
|
||||
) : TemporaryUriDeleter {
|
||||
override fun delete(uri: Uri?) {
|
||||
deleteLambda(uri)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.lambda
|
||||
|
||||
fun assert(lambdaRecorder: LambdaRecorder): LambdaRecorderAssertions {
|
||||
return lambdaRecorder.assertions()
|
||||
}
|
||||
|
||||
class LambdaRecorderAssertions internal constructor(
|
||||
private val parametersSequence: List<List<Any?>>,
|
||||
) {
|
||||
fun isCalledOnce(): CalledOnceParametersAssertions {
|
||||
return CalledOnceParametersAssertions(
|
||||
assertions = isCalledExactly(1)
|
||||
)
|
||||
}
|
||||
|
||||
fun isNeverCalled() {
|
||||
isCalledExactly(0)
|
||||
}
|
||||
|
||||
fun isCalledExactly(times: Int): ParametersAssertions {
|
||||
if (parametersSequence.size != times) {
|
||||
throw AssertionError("Expected to be called $times, but was called ${parametersSequence.size} times")
|
||||
}
|
||||
return ParametersAssertions(parametersSequence)
|
||||
}
|
||||
}
|
||||
|
||||
class CalledOnceParametersAssertions internal constructor(private val assertions: ParametersAssertions) {
|
||||
fun with(vararg matchers: ParameterMatcher) {
|
||||
assertions.withSequence(matchers.toList())
|
||||
}
|
||||
|
||||
fun withNoParameter() {
|
||||
assertions.withNoParameter()
|
||||
}
|
||||
}
|
||||
|
||||
class ParametersAssertions internal constructor(
|
||||
private val parametersSequence: List<List<Any?>>
|
||||
) {
|
||||
fun withSequence(vararg matchersSequence: List<ParameterMatcher>) {
|
||||
if (parametersSequence.size != matchersSequence.size) {
|
||||
throw AssertionError("Lambda was called ${parametersSequence.size} times, but only ${matchersSequence.size} assertions were provided")
|
||||
}
|
||||
parametersSequence.zip(matchersSequence).forEachIndexed { invocationIndex, (parameters, matchers) ->
|
||||
if (parameters.size != matchers.size) {
|
||||
throw AssertionError("Expected ${matchers.size} parameters, but got ${parameters.size} parameters during invocation #$invocationIndex")
|
||||
}
|
||||
parameters.zip(matchers).forEachIndexed { paramIndex, (param, matcher) ->
|
||||
if (!matcher.match(param)) {
|
||||
throw AssertionError(
|
||||
"Parameter #$paramIndex does not match the expected value (actual=$param,expected=$matcher) during invocation #$invocationIndex"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun withNoParameter() {
|
||||
if (parametersSequence.any { it.isNotEmpty() }) {
|
||||
throw AssertionError("Expected no parameters, but got some")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.lambda
|
||||
|
||||
fun lambdaError(
|
||||
message: String = "This lambda should never be called."
|
||||
): Nothing {
|
||||
throw AssertionError(message)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.lambda
|
||||
|
||||
/**
|
||||
* A recorder that can be used to record the parameters of lambda invocation.
|
||||
*/
|
||||
abstract class LambdaRecorder internal constructor(
|
||||
private val assertNoInvocation: Boolean,
|
||||
) {
|
||||
private val parametersSequence: MutableList<List<Any?>> = mutableListOf()
|
||||
|
||||
internal fun onInvoke(vararg params: Any?) {
|
||||
if (assertNoInvocation) {
|
||||
lambdaError()
|
||||
}
|
||||
parametersSequence.add(params.toList())
|
||||
}
|
||||
|
||||
fun assertions(): LambdaRecorderAssertions {
|
||||
return LambdaRecorderAssertions(parametersSequence = parametersSequence)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: () -> R
|
||||
): LambdaNoParamRecorder<R> {
|
||||
return LambdaNoParamRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T) -> R
|
||||
): LambdaOneParamRecorder<T, R> {
|
||||
return LambdaOneParamRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2) -> R
|
||||
): LambdaTwoParamsRecorder<T1, T2, R> {
|
||||
return LambdaTwoParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3) -> R
|
||||
): LambdaThreeParamsRecorder<T1, T2, T3, R> {
|
||||
return LambdaThreeParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4) -> R
|
||||
): LambdaFourParamsRecorder<T1, T2, T3, T4, R> {
|
||||
return LambdaFourParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4, T5) -> R
|
||||
): LambdaFiveParamsRecorder<T1, T2, T3, T4, T5, R> {
|
||||
return LambdaFiveParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified T6, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4, T5, T6) -> R
|
||||
): LambdaSixParamsRecorder<T1, T2, T3, T4, T5, T6, R> {
|
||||
return LambdaSixParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified T6, reified T7, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4, T5, T6, T7) -> R
|
||||
): LambdaSevenParamsRecorder<T1, T2, T3, T4, T5, T6, T7, R> {
|
||||
return LambdaSevenParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified T6, reified T7, reified T8, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4, T5, T6, T7, T8) -> R
|
||||
): LambdaEightParamsRecorder<T1, T2, T3, T4, T5, T6, T7, T8, R> {
|
||||
return LambdaEightParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified R> lambdaAnyRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (List<Any?>) -> R
|
||||
): LambdaListAnyParamsRecorder<R> {
|
||||
return LambdaListAnyParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
class LambdaNoParamRecorder<out R>(ensureNeverCalled: Boolean, val block: () -> R) : LambdaRecorder(ensureNeverCalled), () -> R {
|
||||
override fun invoke(): R {
|
||||
onInvoke()
|
||||
return block()
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaOneParamRecorder<in T, out R>(ensureNeverCalled: Boolean, val block: (T) -> R) : LambdaRecorder(ensureNeverCalled), (T) -> R {
|
||||
override fun invoke(p: T): R {
|
||||
onInvoke(p)
|
||||
return block(p)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaTwoParamsRecorder<in T1, in T2, out R>(ensureNeverCalled: Boolean, val block: (T1, T2) -> R) : LambdaRecorder(ensureNeverCalled), (T1, T2) -> R {
|
||||
override fun invoke(p1: T1, p2: T2): R {
|
||||
onInvoke(p1, p2)
|
||||
return block(p1, p2)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaThreeParamsRecorder<in T1, in T2, in T3, out R>(ensureNeverCalled: Boolean, val block: (T1, T2, T3) -> R) : LambdaRecorder(
|
||||
ensureNeverCalled
|
||||
), (T1, T2, T3) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3): R {
|
||||
onInvoke(p1, p2, p3)
|
||||
return block(p1, p2, p3)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaFourParamsRecorder<in T1, in T2, in T3, in T4, out R>(ensureNeverCalled: Boolean, val block: (T1, T2, T3, T4) -> R) : LambdaRecorder(
|
||||
ensureNeverCalled
|
||||
), (T1, T2, T3, T4) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4): R {
|
||||
onInvoke(p1, p2, p3, p4)
|
||||
return block(p1, p2, p3, p4)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaFiveParamsRecorder<in T1, in T2, in T3, in T4, in T5, out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (T1, T2, T3, T4, T5) -> R,
|
||||
) : LambdaRecorder(
|
||||
ensureNeverCalled
|
||||
), (T1, T2, T3, T4, T5) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5): R {
|
||||
onInvoke(p1, p2, p3, p4, p5)
|
||||
return block(p1, p2, p3, p4, p5)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaSixParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (T1, T2, T3, T4, T5, T6) -> R,
|
||||
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6): R {
|
||||
onInvoke(p1, p2, p3, p4, p5, p6)
|
||||
return block(p1, p2, p3, p4, p5, p6)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaSevenParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (T1, T2, T3, T4, T5, T6, T7) -> R,
|
||||
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7): R {
|
||||
onInvoke(p1, p2, p3, p4, p5, p6, p7)
|
||||
return block(p1, p2, p3, p4, p5, p6, p7)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaEightParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, in T8, out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (T1, T2, T3, T4, T5, T6, T7, T8) -> R,
|
||||
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7, T8) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7, p8: T8): R {
|
||||
onInvoke(p1, p2, p3, p4, p5, p6, p7, p8)
|
||||
return block(p1, p2, p3, p4, p5, p6, p7, p8)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaNineParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, in T8, in T9, out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R,
|
||||
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7, p8: T8, p9: T9): R {
|
||||
onInvoke(p1, p2, p3, p4, p5, p6, p7, p8, p9)
|
||||
return block(p1, p2, p3, p4, p5, p6, p7, p8, p9)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaListAnyParamsRecorder<out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (List<Any?>) -> R,
|
||||
) : LambdaRecorder(ensureNeverCalled), (List<Any?>) -> R {
|
||||
override fun invoke(p: List<Any?>): R {
|
||||
onInvoke(*p.toTypedArray())
|
||||
return block(p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.lambda
|
||||
|
||||
/**
|
||||
* A matcher that can be used to match parameters in lambda calls.
|
||||
* This is useful to assert that a lambda has been called with specific parameters.
|
||||
*/
|
||||
interface ParameterMatcher {
|
||||
fun match(param: Any?): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that matches a specific value.
|
||||
* Can be used to assert that a lambda has been called with a specific value.
|
||||
*/
|
||||
fun <T> value(expectedValue: T) = object : ParameterMatcher {
|
||||
override fun match(param: Any?) = param == expectedValue
|
||||
override fun toString(): String = "value($expectedValue)"
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that matches a value based on a condition.
|
||||
* Can be used to assert that a lambda has been called with a value that satisfies a specific condition.
|
||||
*/
|
||||
fun <T> matching(check: (T) -> Boolean) = object : ParameterMatcher {
|
||||
override fun match(param: Any?): Boolean {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return (param as? T)?.let { check(it) } ?: false
|
||||
}
|
||||
override fun toString(): String = "matching(condition)"
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that matches any value.
|
||||
* Can be used when we don't care about the value of a parameter.
|
||||
*/
|
||||
fun any() = object : ParameterMatcher {
|
||||
override fun match(param: Any?) = true
|
||||
override fun toString(): String = "any()"
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that matches any non null value
|
||||
* Can be used when we don't care about the value of a parameter, just about its nullability.
|
||||
*/
|
||||
fun nonNull() = object : ParameterMatcher {
|
||||
override fun match(param: Any?) = param != null
|
||||
override fun toString(): String = "nonNull()"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.node
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.EmptyNodeView
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.AssistedNodeFactory
|
||||
import io.element.android.libraries.architecture.NodeFactoriesBindings
|
||||
import io.element.android.libraries.di.DependencyInjectionGraphOwner
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* A parent Node that can create a single type of child Node using the provided factory.
|
||||
* This is useful to test a Feature entry point, by providing a fake parent that can create a
|
||||
* child Node.
|
||||
*/
|
||||
class TestParentNode<Child : Node>(
|
||||
private val childNodeClass: KClass<out Node>,
|
||||
private val childNodeFactory: (buildContext: BuildContext, plugins: List<Plugin>) -> Child,
|
||||
) : DependencyInjectionGraphOwner,
|
||||
Node(
|
||||
buildContext = BuildContext.Companion.root(savedStateMap = null),
|
||||
plugins = emptyList(),
|
||||
view = EmptyNodeView,
|
||||
) {
|
||||
override val graph: NodeFactoriesBindings = NodeFactoriesBindings {
|
||||
mapOf(
|
||||
childNodeClass to AssistedNodeFactory { buildContext, plugins ->
|
||||
childNodeFactory(buildContext, plugins)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Inline factory function with reified type parameter
|
||||
inline fun <reified Child : Node> create(
|
||||
noinline childNodeFactory: (buildContext: BuildContext, plugins: List<Plugin>) -> Child,
|
||||
): TestParentNode<Child> {
|
||||
return TestParentNode(Child::class, childNodeFactory)
|
||||
}
|
||||
}
|
||||
}
|
||||
1
tests/uitests/.gitignore
vendored
Normal file
1
tests/uitests/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
59
tests/uitests/build.gradle.kts
Normal file
59
tests/uitests/build.gradle.kts
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import extension.allFeaturesImpl
|
||||
import extension.allLibrariesImpl
|
||||
import extension.allServicesImpl
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.paparazzi)
|
||||
}
|
||||
|
||||
android {
|
||||
// Keep it as short as possible
|
||||
namespace = "ui"
|
||||
}
|
||||
|
||||
tasks.withType(Test::class.java) {
|
||||
// Don't fail the test run if there are no tests, this can happen if we run them with screenshot test disabled
|
||||
failOnNoDiscoveredTests = false
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Paparazzi 1.3.2 workaround (see https://github.com/cashapp/paparazzi/blob/master/CHANGELOG.md#132---2024-01-13)
|
||||
constraints.add("testImplementation", "com.google.guava:guava") {
|
||||
attributes {
|
||||
attribute(
|
||||
TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE,
|
||||
objects.named(TargetJvmEnvironment::class.java, TargetJvmEnvironment.STANDARD_JVM)
|
||||
)
|
||||
}
|
||||
because(
|
||||
"LayoutLib and sdk-common depend on Guava's -jre published variant." +
|
||||
"See https://github.com/cashapp/paparazzi/issues/906."
|
||||
)
|
||||
}
|
||||
|
||||
implementation(libs.showkase)
|
||||
|
||||
// TODO There is a Resources.NotFoundException maybe due to the mipmap, even if we have
|
||||
// `testOptions { unitTests.isIncludeAndroidResources = true }` in the app build.gradle.kts file
|
||||
// implementation(projects.app)
|
||||
implementation(projects.appnav)
|
||||
allLibrariesImpl()
|
||||
allServicesImpl()
|
||||
allFeaturesImpl(project)
|
||||
implementation(projects.appicon.element)
|
||||
implementation(projects.appicon.enterprise)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.parameter.injector)
|
||||
testImplementation(projects.libraries.designsystem)
|
||||
testImplementation(libs.test.composable.preview.scanner)
|
||||
}
|
||||
0
tests/uitests/consumer-rules.pro
Normal file
0
tests/uitests/consumer-rules.pro
Normal file
18
tests/uitests/src/test/kotlin/base/BaseDeviceConfig.kt
Normal file
18
tests/uitests/src/test/kotlin/base/BaseDeviceConfig.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package base
|
||||
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
|
||||
enum class BaseDeviceConfig(
|
||||
val deviceConfig: DeviceConfig,
|
||||
) {
|
||||
NEXUS_5(DeviceConfig.NEXUS_5),
|
||||
// PIXEL_C(DeviceConfig.PIXEL_C),
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package base
|
||||
|
||||
import com.google.testing.junit.testparameterinjector.TestParameterValuesProvider
|
||||
import sergio.sastre.composable.preview.scanner.android.AndroidComposablePreviewScanner
|
||||
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
|
||||
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
|
||||
|
||||
// Make sure we don't import Compound previews by mistake
|
||||
private val PACKAGE_TREES = arrayOf(
|
||||
"io.element.android.features",
|
||||
"io.element.android.libraries",
|
||||
"io.element.android.services",
|
||||
"io.element.android.appicon",
|
||||
"io.element.android.appnav",
|
||||
"io.element.android.x",
|
||||
)
|
||||
|
||||
object ComposablePreviewProvider : TestParameterValuesProvider() {
|
||||
val values: List<IndexedValue<ComposablePreview<AndroidPreviewInfo>>> by lazy {
|
||||
AndroidComposablePreviewScanner()
|
||||
.scanPackageTrees(*PACKAGE_TREES)
|
||||
.getPreviews()
|
||||
.filter { composablePreview -> composablePreview.methodName.endsWith("A11yPreview").not() }
|
||||
.withIndex()
|
||||
.toList()
|
||||
}
|
||||
|
||||
override fun provideValues(context: Context): List<IndexedValue<ComposablePreview<AndroidPreviewInfo>>> = values
|
||||
}
|
||||
|
||||
object ComposableA11yPreviewProvider : TestParameterValuesProvider() {
|
||||
private val values: List<ComposablePreview<AndroidPreviewInfo>> by lazy {
|
||||
AndroidComposablePreviewScanner()
|
||||
.scanPackageTrees(*PACKAGE_TREES)
|
||||
.getPreviews()
|
||||
.filter { composablePreview -> composablePreview.methodName.endsWith("A11yPreview") }
|
||||
.toList()
|
||||
}
|
||||
|
||||
override fun provideValues(context: Context): List<ComposablePreview<AndroidPreviewInfo>> = values
|
||||
}
|
||||
|
||||
object Shard1ComposablePreviewProvider : TestParameterValuesProvider() {
|
||||
override fun provideValues(context: Context): List<ComposablePreview<AndroidPreviewInfo>> =
|
||||
ComposablePreviewProvider.values.filter { it.index % 4 == 0 }.map { it.value }
|
||||
}
|
||||
|
||||
object Shard2ComposablePreviewProvider : TestParameterValuesProvider() {
|
||||
override fun provideValues(context: Context): List<ComposablePreview<AndroidPreviewInfo>> =
|
||||
ComposablePreviewProvider.values.filter { it.index % 4 == 1 }.map { it.value }
|
||||
}
|
||||
|
||||
object Shard3ComposablePreviewProvider : TestParameterValuesProvider() {
|
||||
override fun provideValues(context: Context): List<ComposablePreview<AndroidPreviewInfo>> =
|
||||
ComposablePreviewProvider.values.filter { it.index % 4 == 2 }.map { it.value }
|
||||
}
|
||||
|
||||
object Shard4ComposablePreviewProvider : TestParameterValuesProvider() {
|
||||
override fun provideValues(context: Context): List<ComposablePreview<AndroidPreviewInfo>> =
|
||||
ComposablePreviewProvider.values.filter { it.index % 4 == 3 }.map { it.value }
|
||||
}
|
||||
139
tests/uitests/src/test/kotlin/base/ScreenshotTest.kt
Normal file
139
tests/uitests/src/test/kotlin/base/ScreenshotTest.kt
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package base
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.unit.Density
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import app.cash.paparazzi.RenderExtension
|
||||
import app.cash.paparazzi.TestName
|
||||
import com.android.resources.NightMode
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
|
||||
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
|
||||
import java.util.Locale
|
||||
|
||||
object ScreenshotTest {
|
||||
val defaultDeviceConfig = BaseDeviceConfig.NEXUS_5.deviceConfig
|
||||
|
||||
fun runTest(
|
||||
paparazzi: Paparazzi,
|
||||
preview: ComposablePreview<AndroidPreviewInfo>,
|
||||
localeStr: String,
|
||||
) {
|
||||
val locale = localeStr.toLocale()
|
||||
// Needed for regional settings, as first day of week
|
||||
Locale.setDefault(locale)
|
||||
|
||||
paparazzi.fixScreenshotName(preview, localeStr)
|
||||
paparazzi.snapshot {
|
||||
CompositionLocalProvider(
|
||||
LocalInspectionMode provides true,
|
||||
LocalDensity provides Density(
|
||||
density = LocalDensity.current.density,
|
||||
fontScale = 1.0f,
|
||||
),
|
||||
LocalConfiguration provides Configuration().apply {
|
||||
setLocales(LocaleList(locale))
|
||||
uiMode = preview.previewInfo.uiMode
|
||||
},
|
||||
) {
|
||||
ElementTheme {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
) {
|
||||
preview()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val testNameField = Paparazzi::class.java.getDeclaredField("testName").apply {
|
||||
isAccessible = true
|
||||
}
|
||||
|
||||
private fun Paparazzi.fixScreenshotName(preview: ComposablePreview<AndroidPreviewInfo>, locale: String) {
|
||||
val id = listOf(createScreenshotIdFor(preview), locale)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString("_")
|
||||
val packageName = preview.declaringClass
|
||||
// Remove common prefix
|
||||
.replace("io.element.android.", "")
|
||||
.split(".")
|
||||
// Remove class name
|
||||
.dropLast(1)
|
||||
.joinToString(".")
|
||||
val testName = TestName(
|
||||
packageName = packageName,
|
||||
className = preview.methodName.replace("Preview", ""),
|
||||
methodName = id
|
||||
)
|
||||
testNameField.set(this, testName)
|
||||
}
|
||||
|
||||
private fun String.toLocale(): Locale {
|
||||
return when (this) {
|
||||
"en" -> Locale.US
|
||||
"fr" -> Locale.FRANCE
|
||||
"de" -> Locale.GERMAN
|
||||
else -> Locale.Builder().setLanguage(this).build()
|
||||
}
|
||||
}
|
||||
|
||||
fun createScreenshotIdFor(preview: ComposablePreview<AndroidPreviewInfo>) = buildList {
|
||||
// `name` here can be `Day`, `Night`, or nothing at all
|
||||
if (preview.previewInfo.name.isNotEmpty()) {
|
||||
add(preview.previewInfo.name)
|
||||
}
|
||||
if (preview.previewInfo.group.isNotEmpty()) {
|
||||
add(preview.previewInfo.group)
|
||||
}
|
||||
// If it's a day/night preview, we should add an index to be consistent even if there is only version of this composable
|
||||
val needsIndex = preview.previewInfo.name == "Day" || preview.previewInfo.name == "Night"
|
||||
if (preview.previewIndex != null || needsIndex) {
|
||||
add((preview.previewIndex ?: 0).toString())
|
||||
}
|
||||
}.joinToString("_")
|
||||
|
||||
object PaparazziPreviewRule {
|
||||
fun createFor(
|
||||
preview: ComposablePreview<AndroidPreviewInfo>,
|
||||
locale: String,
|
||||
deviceConfig: DeviceConfig = ScreenshotTest.defaultDeviceConfig,
|
||||
renderExtensions: Set<RenderExtension> = setOf(),
|
||||
): Paparazzi {
|
||||
val densityScale = deviceConfig.density.dpiValue / 160f
|
||||
val customScreenHeight = preview.previewInfo.heightDp.takeIf { it >= 0 }?.let { it * densityScale }?.toInt()
|
||||
return Paparazzi(
|
||||
deviceConfig = deviceConfig.copy(
|
||||
nightMode = when (preview.previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) {
|
||||
true -> NightMode.NIGHT
|
||||
false -> NightMode.NOTNIGHT
|
||||
},
|
||||
locale = locale,
|
||||
softButtons = false,
|
||||
screenHeight = customScreenHeight ?: deviceConfig.screenHeight,
|
||||
),
|
||||
maxPercentDifference = 0.01,
|
||||
renderExtensions = renderExtensions,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package translations
|
||||
|
||||
import android.content.res.Configuration
|
||||
import base.ComposablePreviewProvider
|
||||
import base.PaparazziPreviewRule
|
||||
import base.ScreenshotTest
|
||||
import com.google.testing.junit.testparameterinjector.TestParameter
|
||||
import com.google.testing.junit.testparameterinjector.TestParameterInjector
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
|
||||
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
|
||||
|
||||
/**
|
||||
* Test that takes a preview and a locale and runs a screenshot test on it.
|
||||
*/
|
||||
@RunWith(TestParameterInjector::class)
|
||||
class TranslationsScreenshotTest(
|
||||
@TestParameter(valuesProvider = ComposablePreviewProvider::class)
|
||||
val indexedPreview: IndexedValue<ComposablePreview<AndroidPreviewInfo>>,
|
||||
@TestParameter(value = ["de"])
|
||||
val localeStr: String,
|
||||
) {
|
||||
@get:Rule
|
||||
val paparazziRule = PaparazziPreviewRule.createFor(indexedPreview.value, locale = localeStr)
|
||||
|
||||
@Test
|
||||
fun snapshot() {
|
||||
val (_, preview) = indexedPreview
|
||||
// Skip for dark mode screenshots
|
||||
if (preview.previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) {
|
||||
return
|
||||
}
|
||||
// Skip for design system screenshots
|
||||
if (preview.previewInfo.name.startsWith("io.element.android.libraries.designsystem")) {
|
||||
return
|
||||
}
|
||||
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = localeStr)
|
||||
}
|
||||
}
|
||||
43
tests/uitests/src/test/kotlin/ui/PreviewA11yTest.kt
Normal file
43
tests/uitests/src/test/kotlin/ui/PreviewA11yTest.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package ui
|
||||
|
||||
import app.cash.paparazzi.accessibility.AccessibilityRenderExtension
|
||||
import base.ComposableA11yPreviewProvider
|
||||
import base.PaparazziPreviewRule
|
||||
import base.ScreenshotTest
|
||||
import com.google.testing.junit.testparameterinjector.TestParameter
|
||||
import com.google.testing.junit.testparameterinjector.TestParameterInjector
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
|
||||
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
|
||||
|
||||
/**
|
||||
* Test that takes a preview and runs a screenshot test on it.
|
||||
* It uses [ComposableA11yPreviewProvider] to test only previews that ends with "A11yPreview".
|
||||
*/
|
||||
@RunWith(TestParameterInjector::class)
|
||||
class PreviewA11yTest(
|
||||
@TestParameter(valuesProvider = ComposableA11yPreviewProvider::class)
|
||||
val preview: ComposablePreview<AndroidPreviewInfo>,
|
||||
) {
|
||||
@get:Rule
|
||||
val paparazziRule = PaparazziPreviewRule.createFor(
|
||||
preview = preview,
|
||||
locale = "en",
|
||||
renderExtensions = setOf(AccessibilityRenderExtension()),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun snapshot() {
|
||||
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en")
|
||||
}
|
||||
}
|
||||
38
tests/uitests/src/test/kotlin/ui/PreviewShard1Test.kt
Normal file
38
tests/uitests/src/test/kotlin/ui/PreviewShard1Test.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package ui
|
||||
|
||||
import base.PaparazziPreviewRule
|
||||
import base.ScreenshotTest
|
||||
import base.Shard1ComposablePreviewProvider
|
||||
import com.google.testing.junit.testparameterinjector.TestParameter
|
||||
import com.google.testing.junit.testparameterinjector.TestParameterInjector
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
|
||||
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
|
||||
|
||||
/**
|
||||
* Test that takes a preview and runs a screenshot test on it.
|
||||
* It uses a sharded preview provider so multiple 'shards' can run in parallel, optimizing CPU and time usage.
|
||||
*/
|
||||
@RunWith(TestParameterInjector::class)
|
||||
class PreviewShard1Test(
|
||||
@TestParameter(valuesProvider = Shard1ComposablePreviewProvider::class)
|
||||
val preview: ComposablePreview<AndroidPreviewInfo>,
|
||||
) {
|
||||
@get:Rule
|
||||
val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en")
|
||||
|
||||
@Test
|
||||
fun snapshot() {
|
||||
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en")
|
||||
}
|
||||
}
|
||||
38
tests/uitests/src/test/kotlin/ui/PreviewShard2Test.kt
Normal file
38
tests/uitests/src/test/kotlin/ui/PreviewShard2Test.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package ui
|
||||
|
||||
import base.PaparazziPreviewRule
|
||||
import base.ScreenshotTest
|
||||
import base.Shard2ComposablePreviewProvider
|
||||
import com.google.testing.junit.testparameterinjector.TestParameter
|
||||
import com.google.testing.junit.testparameterinjector.TestParameterInjector
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
|
||||
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
|
||||
|
||||
/**
|
||||
* Test that takes a preview and runs a screenshot test on it.
|
||||
* It uses a sharded preview provider so multiple 'shards' can run in parallel, optimizing CPU and time usage.
|
||||
*/
|
||||
@RunWith(TestParameterInjector::class)
|
||||
class PreviewShard2Test(
|
||||
@TestParameter(valuesProvider = Shard2ComposablePreviewProvider::class)
|
||||
val preview: ComposablePreview<AndroidPreviewInfo>,
|
||||
) {
|
||||
@get:Rule
|
||||
val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en")
|
||||
|
||||
@Test
|
||||
fun snapshot() {
|
||||
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en")
|
||||
}
|
||||
}
|
||||
38
tests/uitests/src/test/kotlin/ui/PreviewShard3Test.kt
Normal file
38
tests/uitests/src/test/kotlin/ui/PreviewShard3Test.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package ui
|
||||
|
||||
import base.PaparazziPreviewRule
|
||||
import base.ScreenshotTest
|
||||
import base.Shard3ComposablePreviewProvider
|
||||
import com.google.testing.junit.testparameterinjector.TestParameter
|
||||
import com.google.testing.junit.testparameterinjector.TestParameterInjector
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
|
||||
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
|
||||
|
||||
/**
|
||||
* Test that takes a preview and runs a screenshot test on it.
|
||||
* It uses a sharded preview provider so multiple 'shards' can run in parallel, optimizing CPU and time usage.
|
||||
*/
|
||||
@RunWith(TestParameterInjector::class)
|
||||
class PreviewShard3Test(
|
||||
@TestParameter(valuesProvider = Shard3ComposablePreviewProvider::class)
|
||||
val preview: ComposablePreview<AndroidPreviewInfo>,
|
||||
) {
|
||||
@get:Rule
|
||||
val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en")
|
||||
|
||||
@Test
|
||||
fun snapshot() {
|
||||
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en")
|
||||
}
|
||||
}
|
||||
38
tests/uitests/src/test/kotlin/ui/PreviewShard4Test.kt
Normal file
38
tests/uitests/src/test/kotlin/ui/PreviewShard4Test.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package ui
|
||||
|
||||
import base.PaparazziPreviewRule
|
||||
import base.ScreenshotTest
|
||||
import base.Shard4ComposablePreviewProvider
|
||||
import com.google.testing.junit.testparameterinjector.TestParameter
|
||||
import com.google.testing.junit.testparameterinjector.TestParameterInjector
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
|
||||
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
|
||||
|
||||
/**
|
||||
* Test that takes a preview and runs a screenshot test on it.
|
||||
* It uses a sharded preview provider so multiple 'shards' can run in parallel, optimizing CPU and time usage.
|
||||
*/
|
||||
@RunWith(TestParameterInjector::class)
|
||||
class PreviewShard4Test(
|
||||
@TestParameter(valuesProvider = Shard4ComposablePreviewProvider::class)
|
||||
val preview: ComposablePreview<AndroidPreviewInfo>,
|
||||
) {
|
||||
@get:Rule
|
||||
val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en")
|
||||
|
||||
@Test
|
||||
fun snapshot() {
|
||||
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0db3553ee5ed730fda10fa78bb19f3714c7ecdf2b2b82b0946335aaede2c3c61
|
||||
size 13557
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:901b31047da757e8f0cd391dd7bbbf9d7c20e66bb4c21497acbe1616648926ba
|
||||
size 7084
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d588b54e770dd21a63208bdd596f3e7f3c4074b8cbfa33affcc8750c4bf89c2d
|
||||
size 16187
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:56c6325a42172a4e9b8675e9777292b329f2262435cc90cb2c918171656d6156
|
||||
size 17021
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:398dd5c12a4561286d3d9471e7bfd4e2797d2832ae51dec830d6c97b76413fc2
|
||||
size 16190
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
|
||||
size 3642
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dfcbb11744ad6ab80a011067542140a926e86862380d3be25069649b665b5b3d
|
||||
size 8279
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d42f53b0b77ce6f20c61c0ee491df157e878611a7eeeb44c9cb6411b348638da
|
||||
size 37102
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:249cb85b4d269ca7829a00587f6840cbf1f10e51e28568186e419b38e1126610
|
||||
size 24285
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
|
||||
size 3659
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:11fb4712defab1a03444cb18403d3555e6d6f6069915252a36bea0c2c80a2187
|
||||
size 7024
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f0569db10eadf71f6443a0693c67f92189eb7ca52eb73c038a9c1733c856b9a7
|
||||
size 35103
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:75bc869a9591043ab8bf5ab9944c2152b7f72dccac2ab8a541d65fc2c97199ae
|
||||
size 22311
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e0df5eff771a20895cb1f79b00b1991a84ea505b0b60741db8d72bc46173ad6c
|
||||
size 8775
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1a89875acd53deed14d47775c283323036960476d0bb52b016f5eaf5bd800a8a
|
||||
size 7408
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:071914fc3e7bbc2fc6ef8a1b4544b9126c05dd08b2123666d320223582423226
|
||||
size 5632
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e9df6017bd0771e1337f82b75d13a0a8a572156e6900ae72f208e9bb2aaddb7
|
||||
size 7717
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:811bd4534f4f16530cea1489b62d393e2fe7bf1b18b859a924333cd9d309f8dd
|
||||
size 5566
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:030971f2c5f8c19f47fed2fa207c6d84d536c603fa7705ea6c288736684b7868
|
||||
size 7553
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b33e598d33b6f514510adceb319573e0fded75719b2ab01265dce08ef180d9ec
|
||||
size 25833
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:153ec618066e0dc9738a337bac5bbcae459e8143e5acac2846f144a4ad62d48e
|
||||
size 27953
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9d27f8fbaed8cdbb6ad03414cf6f8c2d38b3269731ee72f5fe9eab1ad6afe9df
|
||||
size 21819
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a82a01623f008b3a374cfb4c08b1a592bf7eedfca57b64fd2e819b53056be626
|
||||
size 24309
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:97fc1dfcde1dcaf67c6cfacc0fb172b053668d418165cb1d8f67eb18a220a563
|
||||
size 26313
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4227570b201034be916a714dcf27c9ab4d17d6cc3d47a8eab9ffae3b9b61bb4a
|
||||
size 19967
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f78f491a8643f02587d8cd8845e82071483e76977b2e7735d2791869c6f5fe8e
|
||||
size 22370
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0a7b8718daff4a22061e556001827e4cf58330e0de36cfa331874d9d2f6638c6
|
||||
size 18493
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:46dc3f8fec381c5102af2a08ebaa6bb5d5f2a05ecd5254bb8cae14766bb74072
|
||||
size 21167
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0aa4f30dac0e0eadadf40f2b699c446fe4d236dbc9b373aeb1d115d08ce76e8b
|
||||
size 17440
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0297e771c4370ede91623c024d63cf750826578381d2f08885e8246fc4c8c82f
|
||||
size 83236
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:407a886dee6739c1075f8865603250ab0a2a7c79d4b8cdf8ed2f7617c8cdd9aa
|
||||
size 82078
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b5cfe3523c7a0fe50dbac6fe6ce842999c60b205d7347697c72b89074bf29c56
|
||||
size 76369
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4bdb9e6d6d8423e9d7a847095b2a96fb47b2ed6fee977c6e163682b5f20e43db
|
||||
size 74563
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:48f54197f74cf0675e04a83efbb278728568024bcec38a8553b623cdba384e50
|
||||
size 60460
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b2e2356a3f8521649d6ad2c10f654142f87141e3f747db5a6f6ba25483c8171a
|
||||
size 59516
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:56f3fe80ef0161c25a8bc52a1649dde2c113fd2e91f0e7462bf2329d45d83508
|
||||
size 9451
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1284c9d7c90881d5affcabbc5a14dad86234ff9da2c32d2575f55876e89642e6
|
||||
size 11705
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user