diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 8f5c6286636..506090f9a32 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -34,3 +34,22 @@ jobs: with: name: pull-request-build path: "app/build/outputs/apk/prerelease/debug/*.apk" + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results + path: | + **/build/reports/tests/ + **/build/test-results/ + include-hidden-files: false + + - name: Upload Lint Results + if: always() + uses: actions/upload-artifact@v7 + with: + name: lint-results + path: | + **/build/reports/lint-results-*.html + **/build/reports/lint-results-*.xml diff --git a/build.gradle.kts b/build.gradle.kts index baf721a82cc..ed2df766074 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.buildkonfig) apply false // Universal build config alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.detekt) apply false alias(libs.plugins.dokka) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.multiplatform) apply false diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 276a6945bdf..9024623a37e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.multiplatform.library) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.compiler) + alias(libs.plugins.detekt) } kotlin { @@ -16,6 +17,11 @@ kotlin { androidResources { enable = true } + + lint { + checkTestSources = true + checkDependencies = true + } } jvm() @@ -47,11 +53,37 @@ kotlin { implementation(libs.activity.compose) implementation(libs.preference.ktx) } + + commonTest.dependencies { + implementation(libs.compose.ui.test) + implementation(libs.kotlin.test) + } + + jvmTest.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlin.reflect) + implementation(libs.kotlin.test) + } } } +detekt { + buildUponDefaultConfig = true + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + source.setFrom( + "src/commonMain/kotlin", + "src/androidMain/kotlin", + "src/jvmMain/kotlin", + "src/commonTest/kotlin", + "src/jvmTest/kotlin", + ) +} + dependencies { androidRuntimeClasspath(libs.compose.ui.tooling) + + detektPlugins(libs.detekt.formatting) + detektPlugins(libs.compose.rules.detekt) } compose.resources { diff --git a/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/CloudStreamRipple.kt b/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/CloudStreamRipple.kt index 2d5b50363de..9f104f8bdff 100644 --- a/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/CloudStreamRipple.kt +++ b/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/CloudStreamRipple.kt @@ -3,19 +3,20 @@ package com.lagradost.cloudstream4.ui.components import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import com.lagradost.cloudstream4.ui.theme.CloudStreamTheme /** * Applies a ripple indication styled to match the app's selected theme. */ +@Composable fun Modifier.cloudStreamRipple( interactionSource: MutableInteractionSource, bounded: Boolean = true, -): Modifier = composed { +): Modifier { val colors = CloudStreamTheme.colors - this.indication( + return this.indication( interactionSource = interactionSource, indication = ripple(bounded = bounded, color = colors.onBackground), ) diff --git a/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/ProfilePicture.kt b/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/ProfilePicture.kt index 6b3c0c8374f..9997a2264e4 100644 --- a/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/ProfilePicture.kt +++ b/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/ProfilePicture.kt @@ -29,7 +29,13 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource enum class ProfileImage { - DARK_BLUE, BLUE, ORANGE, PINK, PURPLE, RED, TEAL, + BLUE, + DARK_BLUE, + ORANGE, + PINK, + PURPLE, + RED, + TEAL, } @Composable @@ -50,18 +56,20 @@ private fun ProfileImage.toRes(): DrawableResource = when (this) { * otherwise falls back to the local [profileImage] background. * * @param profileImage Local background image fallback + * @param modifier Modifier to be applied to the profile picture container * @param size Diameter of the circle, default 50.dp * @param profilePictureUrl Optional remote URL to load via Coil */ @Composable fun ProfilePicture( profileImage: ProfileImage, + modifier: Modifier = Modifier, size: Dp = 50.dp, profilePictureUrl: String? = null, ) { val colors = CloudStreamTheme.colors Box( - modifier = Modifier + modifier = modifier .size(size) .border(2.dp, colors.onBackground.copy(alpha = 0.2f), CircleShape) .clip(CircleShape), diff --git a/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/settings/SettingsItem.kt b/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/settings/SettingsItem.kt index 588cdb7861f..e7dfebf2d73 100644 --- a/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/settings/SettingsItem.kt +++ b/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/components/settings/SettingsItem.kt @@ -36,7 +36,7 @@ fun SettingsItem( subtitle: String? = null, icon: ImageVector? = null, focusRequester: FocusRequester? = null, - onClick: () -> Unit, + onClick: () -> Unit = {}, ) { val colors = CloudStreamTheme.colors val isTV = remember { DeviceLayout.isLayout(DeviceLayout.TV) } @@ -89,7 +89,6 @@ private fun SettingsItemPreview() { title = stringResource(Res.string.category_general), subtitle = stringResource(Res.string.category_general_subtitle), icon = tune, - onClick = {}, ) } } @@ -101,7 +100,6 @@ private fun SettingsItemNoIconPreview() { SettingsItem( title = stringResource(Res.string.category_general), subtitle = stringResource(Res.string.category_general_subtitle), - onClick = {}, ) } } @@ -113,7 +111,6 @@ private fun SettingsItemNoSubtitlePreview() { SettingsItem( title = stringResource(Res.string.category_general), icon = tune, - onClick = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/screens/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/screens/settings/SettingsScreen.kt index 35f80885b21..438fd374574 100644 --- a/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/screens/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/lagradost/cloudstream4/ui/screens/settings/SettingsScreen.kt @@ -128,6 +128,7 @@ fun SettingsScreen( version: SettingsVersionState, onNavigate: (SettingsCategory) -> Unit, onVersionLongClick: () -> Unit = {}, + modifier: Modifier = Modifier, ) { val colors = CloudStreamTheme.colors val isTV = remember { DeviceLayout.isLayout(DeviceLayout.TV) } @@ -138,7 +139,7 @@ fun SettingsScreen( } Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(colors.background) .windowInsetsPadding(WindowInsets.statusBars) diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/CloudStreamRippleTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/CloudStreamRippleTest.kt new file mode 100644 index 00000000000..0e15f283839 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/CloudStreamRippleTest.kt @@ -0,0 +1,139 @@ +package com.lagradost.cloudstream4.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.ui.theme.CloudStreamPalette +import com.lagradost.cloudstream4.ui.theme.CloudStreamTheme +import com.lagradost.cloudstream4.ui.theme.CloudStreamThemeMode +import kotlin.test.Test +import kotlin.test.assertNotEquals + +@OptIn(ExperimentalTestApi::class) +class CloudStreamRippleTest { + + @Test + fun appliesSuccessfullyWhenBounded() = runComposeUiTest { + val interactionSource = MutableInteractionSource() + setContent { + CloudStreamTheme { + Box( + modifier = Modifier + .testTag("target") + .size(64.dp) + .cloudStreamRipple(interactionSource, bounded = true), + ) + } + } + waitForIdle() + + onNodeWithTag("target").assertIsDisplayed() + } + + @Test + fun appliesSuccessfullyWhenUnbounded() = runComposeUiTest { + val interactionSource = MutableInteractionSource() + setContent { + CloudStreamTheme { + Box( + modifier = Modifier + .testTag("target") + .size(64.dp) + .cloudStreamRipple(interactionSource, bounded = false), + ) + } + } + waitForIdle() + + onNodeWithTag("target").assertIsDisplayed() + } + + @Test + fun appliesSuccessfullyUnderEveryThemeMode() = runComposeUiTest { + val modes = CloudStreamThemeMode.entries.filterNot { it == CloudStreamThemeMode.Dynamic } + + setContent { + Column { + modes.forEach { mode -> + val interactionSource = remember { MutableInteractionSource() } + CloudStreamTheme(mode = mode) { + Box( + modifier = Modifier + .testTag("target-${mode.name}") + .size(32.dp) + .cloudStreamRipple(interactionSource), + ) + } + } + } + } + waitForIdle() + + modes.forEach { mode -> + onNodeWithTag("target-${mode.name}").assertIsDisplayed() + } + } + + /** + * Check rhat it emits a real [PressInteraction.Press] into the + * [MutableInteractionSource] and confirms the pixel under the press point + * actually changes once the ripple draws over it. + * + * This test may end up being flaky. If so, it can be dropped. + */ + @Test + fun pressInteractionChangesThePixelsUnderThePress() = runComposeUiTest { + val interactionSource = MutableInteractionSource() + val boxSizeDp = 80 + + setContent { + CloudStreamTheme(mode = CloudStreamThemeMode.Dark) { + Box( + modifier = Modifier + .testTag("target") + .size(boxSizeDp.dp) + .background(CloudStreamPalette.DarkBlackBg) + .cloudStreamRipple(interactionSource), + ) + } + } + waitForIdle() + + val target = onNodeWithTag("target") + val idlePixels = target.captureToImage().toPixelMap() + val centerX = idlePixels.width / 2 + val centerY = idlePixels.height / 2 + val idleCenterColor = idlePixels[centerX, centerY] + + val press = PressInteraction.Press(Offset(centerX.toFloat(), centerY.toFloat())) + interactionSource.tryEmit(press) + waitForIdle() + + val pressedPixels = target.captureToImage().toPixelMap() + val pressedCenterColor = pressedPixels[centerX, centerY] + + assertNotEquals( + idleCenterColor, + pressedCenterColor, + "Expected the ripple to visibly change the pixel under the press point", + ) + + // Release cleanly so the ripple doesn't leak a running animation past the test. + interactionSource.tryEmit(PressInteraction.Release(press)) + waitForIdle() + } +} diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/ProfilePictureTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/ProfilePictureTest.kt new file mode 100644 index 00000000000..80b7ea4d6a7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/ProfilePictureTest.kt @@ -0,0 +1,94 @@ +package com.lagradost.cloudstream4.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.ui.theme.CloudStreamTheme +import kotlin.test.Test + +/** + * The content description ("Profile picture") comes from + * `Res.string.profile_picture_desc`, see composeResources/values/strings.xml. + */ +@OptIn(ExperimentalTestApi::class) +class ProfilePictureTest { + + @Test + fun rendersLocalFallbackImageWhenNoUrlIsProvided() = runComposeUiTest { + setContent { + CloudStreamTheme { + ProfilePicture(profileImage = ProfileImage.BLUE) + } + } + waitForIdle() + + onNodeWithContentDescription("Profile picture").assertIsDisplayed() + } + + @Test + fun rendersRemoteImageWhenUrlIsProvided() = runComposeUiTest { + setContent { + CloudStreamTheme { + ProfilePicture( + profileImage = ProfileImage.TEAL, + profilePictureUrl = "https://example.com/avatar.png", + ) + } + } + waitForIdle() + + onNodeWithContentDescription("Profile picture").assertIsDisplayed() + } + + @Test + fun usesTheDefaultFiftyDpSizeWhenNotOverridden() = runComposeUiTest { + setContent { + CloudStreamTheme { + ProfilePicture(profileImage = ProfileImage.PURPLE) + } + } + waitForIdle() + + onNodeWithContentDescription("Profile picture") + .assertWidthIsEqualTo(50.dp) + .assertHeightIsEqualTo(50.dp) + } + + @Test + fun respectsACustomSize() = runComposeUiTest { + setContent { + CloudStreamTheme { + ProfilePicture(profileImage = ProfileImage.PINK, size = 96.dp) + } + } + waitForIdle() + + onNodeWithContentDescription("Profile picture") + .assertWidthIsEqualTo(96.dp) + .assertHeightIsEqualTo(96.dp) + } + + @Test + fun rendersForEveryProfileImageVariantWithoutCrashing() = runComposeUiTest { + setContent { + CloudStreamTheme { + Column { + for (variant in ProfileImage.entries) { + ProfilePicture(profileImage = variant) + } + } + } + } + waitForIdle() + + onAllNodesWithContentDescription("Profile picture") + .assertCountEquals(ProfileImage.entries.size) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/TvFocusableTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/TvFocusableTest.kt new file mode 100644 index 00000000000..5d80bddcae3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/TvFocusableTest.kt @@ -0,0 +1,120 @@ +package com.lagradost.cloudstream4.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class TvFocusableTest { + + @Test + fun nonTvModeInvokesOnClickOnASingleTap() = runComposeUiTest { + var clickCount = 0 + setContent { + Box( + modifier = Modifier + .size(48.dp) + .testTag("target") + .tvFocusable(isTV = false, onClick = { clickCount++ }), + ) + } + + onNodeWithTag("target").performClick() + waitForIdle() + + assertEquals(1, clickCount) + } + + @Test + fun nonTvModeInvokesOnClickOncePerTap() = runComposeUiTest { + var clickCount = 0 + setContent { + Box( + modifier = Modifier + .size(48.dp) + .testTag("target") + .tvFocusable(isTV = false, onClick = { clickCount++ }), + ) + } + + val target = onNodeWithTag("target") + target.performClick() + target.performClick() + target.performClick() + waitForIdle() + + assertEquals(3, clickCount) + } + + @Test + fun tvModeFirstTapOnlyRequestsFocusAndDoesNotInvokeOnClick() = runComposeUiTest { + var clickCount = 0 + setContent { + Box( + modifier = Modifier + .size(48.dp) + .testTag("target") + .tvFocusable(isTV = true, onClick = { clickCount++ }), + ) + } + + onNodeWithTag("target").performTouchInput { click() } + waitForIdle() + + assertEquals(0, clickCount) + } + + @Test + fun tvModeSecondTapWhileFocusedInvokesOnClick() = runComposeUiTest { + var clickCount = 0 + setContent { + Box( + modifier = Modifier + .size(48.dp) + .testTag("target") + .tvFocusable(isTV = true, onClick = { clickCount++ }), + ) + } + + val target = onNodeWithTag("target") + target.performTouchInput { click() } // 1st tap: gains focus + waitForIdle() + target.performTouchInput { click() } // 2nd tap: now focused, should click + waitForIdle() + + assertEquals(1, clickCount) + } + + @Test + fun tvModeReportsFocusChangesThroughCallback() = runComposeUiTest { + var focused = false + setContent { + Box( + modifier = Modifier + .size(48.dp) + .testTag("target") + .tvFocusable( + isTV = true, + onClick = {}, + onFocusChanged = { focused = it }, + ), + ) + } + + onNodeWithTag("target").performTouchInput { click() } + waitForIdle() + + assertTrue(focused, "Expected the element to report focus after the first tap") + } +} diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/settings/SettingsItemTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/settings/SettingsItemTest.kt new file mode 100644 index 00000000000..115e95d8308 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/components/settings/SettingsItemTest.kt @@ -0,0 +1,113 @@ +package com.lagradost.cloudstream4.ui.components.settings + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import com.lagradost.cloudstream4.ui.icons.tune +import com.lagradost.cloudstream4.ui.theme.CloudStreamTheme +import com.lagradost.cloudstream4.utils.DeviceLayout +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class SettingsItemTest { + + @BeforeTest + fun resolveDeviceLayout() { + DeviceLayout.update() + } + + @Test + fun displaysTitleAndSubtitleWhenBothAreProvided() = runComposeUiTest { + setContent { + CloudStreamTheme { + SettingsItem( + title = "My Setting", + subtitle = "A helpful subtitle", + onClick = {}, + ) + } + } + waitForIdle() + + onNodeWithText("My Setting").assertIsDisplayed() + onNodeWithText("A helpful subtitle").assertIsDisplayed() + } + + @Test + fun omitsTheSubtitleNodeWhenSubtitleIsNull() = runComposeUiTest { + setContent { + CloudStreamTheme { + SettingsItem(title = "Only Title", subtitle = null, onClick = {}) + } + } + waitForIdle() + + onNodeWithText("Only Title").assertIsDisplayed() + onAllNodesWithText("A helpful subtitle").assertCountEquals(0) + } + + @Test + fun clickingAnywhereOnTheRowInvokesOnClick() = runComposeUiTest { + var clickCount = 0 + setContent { + CloudStreamTheme { + SettingsItem(title = "Clickable Setting", onClick = { clickCount++ }) + } + } + + onNodeWithText("Clickable Setting").performClick() + waitForIdle() + + assertEquals(1, clickCount) + } + + @Test + fun rendersSuccessfullyWithAnIconAttached() = runComposeUiTest { + setContent { + CloudStreamTheme { + SettingsItem(title = "With Icon", subtitle = "Has an icon", icon = tune, onClick = {}) + } + } + waitForIdle() + + onNodeWithText("With Icon").assertIsDisplayed() + onNodeWithText("Has an icon").assertIsDisplayed() + } + + @Test + fun rendersSuccessfullyWithoutAnIcon() = runComposeUiTest { + setContent { + CloudStreamTheme { + SettingsItem(title = "No Icon Here", icon = null, onClick = {}) + } + } + waitForIdle() + + onNodeWithText("No Icon Here").assertIsDisplayed() + } + + @Test + fun multipleClicksEachInvokeOnClick() = runComposeUiTest { + var clickCount = 0 + setContent { + CloudStreamTheme { + SettingsItem(title = "Repeatable", onClick = { clickCount++ }) + } + } + + val node = onNodeWithText("Repeatable") + node.performClick() + node.performClick() + node.performClick() + waitForIdle() + + assertTrue(clickCount == 3, "Expected 3 clicks but got $clickCount") + } +} diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/screens/settings/SettingsScreenTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/screens/settings/SettingsScreenTest.kt new file mode 100644 index 00000000000..9498c288bd5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/screens/settings/SettingsScreenTest.kt @@ -0,0 +1,166 @@ +package com.lagradost.cloudstream4.ui.screens.settings + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.v2.runComposeUiTest +import com.lagradost.cloudstream4.ui.components.ProfileImage +import com.lagradost.cloudstream4.ui.theme.CloudStreamTheme +import com.lagradost.cloudstream4.utils.DeviceLayout +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class SettingsScreenTest { + + @BeforeTest + fun resolveDeviceLayout() { + DeviceLayout.update() + } + + private val profile = SettingsProfileState(name = "Test User", profileImage = ProfileImage.DARK_BLUE) + private val version = SettingsVersionState( + appVersion = "1.0.0-PRE", + commitHash = "abc1234", + buildDate = "Jan 1, 2026 12:00:00 AM", + ) + + @Test + fun displaysTheProfileName() = runComposeUiTest { + setContent { + CloudStreamTheme { + SettingsScreen(profile = profile, version = version, onNavigate = {}) + } + } + waitForIdle() + + onNodeWithText("Test User").assertIsDisplayed() + } + + @Test + fun displaysEveryCategoryTitleAndSubtitle() = runComposeUiTest { + // Resolve the localized strings from within the same composition so this + // test stays correct even if the copy in strings.xml changes. + lateinit var expected: List> + + setContent { + expected = SettingsCategory.entries.map { it.label() to it.subtitle() } + CloudStreamTheme { + SettingsScreen(profile = profile, version = version, onNavigate = {}) + } + } + waitForIdle() + + expected.forEach { (title, subtitle) -> + onNodeWithText(title).assertIsDisplayed() + onNodeWithText(subtitle).assertIsDisplayed() + } + } + + @Test + fun clickingACategoryInvokesOnNavigateWithThatCategory() = runComposeUiTest { + val navigated = mutableListOf() + lateinit var playerTitle: String + + setContent { + playerTitle = SettingsCategory.PLAYER.label() + CloudStreamTheme { + SettingsScreen( + profile = profile, + version = version, + onNavigate = { navigated += it }, + ) + } + } + waitForIdle() + + onNodeWithText(playerTitle).performClick() + waitForIdle() + + assertEquals(listOf(SettingsCategory.PLAYER), navigated) + } + + @Test + fun clickingEachCategoryNavigatesExactlyOnce() = runComposeUiTest { + val navigated = mutableListOf() + lateinit var titlesInOrder: List> + + setContent { + titlesInOrder = SettingsCategory.entries.map { it to it.label() } + CloudStreamTheme { + SettingsScreen( + profile = profile, + version = version, + onNavigate = { navigated += it }, + ) + } + } + waitForIdle() + + titlesInOrder.forEach { (_, title) -> onNodeWithText(title).performClick() } + waitForIdle() + + assertEquals(titlesInOrder.map { it.first }, navigated) + } + + @Test + fun displaysAllThreeVersionChips() = runComposeUiTest { + setContent { + CloudStreamTheme { + SettingsScreen(profile = profile, version = version, onNavigate = {}) + } + } + waitForIdle() + + onNodeWithText(version.appVersion).assertIsDisplayed() + onNodeWithText(version.commitHash).assertIsDisplayed() + onNodeWithText(version.buildDate).assertIsDisplayed() + } + + @Test + fun longPressingTheVersionFooterInvokesOnVersionLongClick() = runComposeUiTest { + var longClicked = false + setContent { + CloudStreamTheme { + SettingsScreen( + profile = profile, + version = version, + onNavigate = {}, + onVersionLongClick = { longClicked = true }, + ) + } + } + waitForIdle() + + onNodeWithText(version.appVersion).performTouchInput { longClick() } + waitForIdle() + + assertTrue(longClicked, "Expected long-pressing the version footer to fire onVersionLongClick") + } + + @Test + fun shortPressingTheVersionFooterDoesNotInvokeOnVersionLongClick() = runComposeUiTest { + var longClicked = false + setContent { + CloudStreamTheme { + SettingsScreen( + profile = profile, + version = version, + onNavigate = {}, + onVersionLongClick = { longClicked = true }, + ) + } + } + waitForIdle() + + onNodeWithText(version.appVersion).performClick() + waitForIdle() + + assertTrue(!longClicked) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamColorSchemeTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamColorSchemeTest.kt new file mode 100644 index 00000000000..5b3affb2063 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamColorSchemeTest.kt @@ -0,0 +1,119 @@ +package com.lagradost.cloudstream4.ui.theme + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CloudStreamColorSchemeTest { + + @Test + fun darkSchemeIsNotLight() { + assertFalse(darkScheme().isLight) + } + + @Test + fun lightSchemeIsLight() { + assertTrue(lightScheme().isLight) + } + + @Test + fun amoledSchemeIsDerivedFromDarkSchemeButNotLight() { + assertFalse(amoledScheme().isLight) + } + + @Test + fun amoledSchemeOverridesAllBackgroundLayersToPureBlack() { + val amoled = amoledScheme() + assertEquals(CloudStreamPalette.AmoledBlack, amoled.background) + assertEquals(CloudStreamPalette.AmoledBlack, amoled.surface) + assertEquals(CloudStreamPalette.AmoledBlack, amoled.surfaceVariant) + assertEquals(CloudStreamPalette.AmoledBlack, amoled.surfaceContainer) + } + + @Test + fun amoledSchemeKeepsDarkSchemeTextAndIconColors() { + // Only backgrounds are overridden on top of darkScheme(); text/icon/primary + // should be carried over unchanged. + val dark = darkScheme() + val amoled = amoledScheme() + assertEquals(dark.onBackground, amoled.onBackground) + assertEquals(dark.onSurfaceVariant, amoled.onSurfaceVariant) + assertEquals(dark.icon, amoled.icon) + assertEquals(dark.primary, amoled.primary) + } + + @Test + fun draculaSchemeIsDark() { + assertFalse(draculaScheme().isLight) + } + + @Test + fun lavenderSchemeIsLight() { + assertTrue(lavenderScheme().isLight) + } + + @Test + fun silentBlueSchemeIsDark() { + assertFalse(silentBlueScheme().isLight) + } + + @Test + fun allNamedSchemesSharePrimaryAndOngoingColors() { + // Every preset (aside from copies that explicitly override it) shares the + // same default primary/ongoing accent colors from the palette. + val schemes = listOf( + darkScheme(), + lightScheme(), + draculaScheme(), + lavenderScheme(), + silentBlueScheme(), + ) + + for (scheme in schemes) { + assertEquals(CloudStreamPalette.Primary, scheme.primary) + assertEquals(CloudStreamPalette.Ongoing, scheme.ongoing) + } + } + + @Test + fun copyOverridesOnlyRequestedFieldsAndPreservesTheRest() { + val original = darkScheme() + val copy = original.copy(primary = CloudStreamPalette.Ongoing) + + assertEquals(CloudStreamPalette.Ongoing, copy.primary) + assertEquals(original.background, copy.background) + assertEquals(original.surface, copy.surface) + assertEquals(original.onBackground, copy.onBackground) + assertEquals(original.isLight, copy.isLight) + } + + @Test + fun copyWithNoArgumentsProducesAnEquivalentButDistinctInstance() { + val original = lightScheme() + val copy = original.copy() + + assertEquals(original.background, copy.background) + assertEquals(original.surface, copy.surface) + assertEquals(original.surfaceVariant, copy.surfaceVariant) + assertEquals(original.surfaceContainer, copy.surfaceContainer) + assertEquals(original.onBackground, copy.onBackground) + assertEquals(original.onSurfaceVariant, copy.onSurfaceVariant) + assertEquals(original.icon, copy.icon) + assertEquals(original.primary, copy.primary) + assertEquals(original.ongoing, copy.ongoing) + assertEquals(original.isLight, copy.isLight) + } + + @Test + fun mutatingASchemeFieldDoesNotAffectAFreshlyBuiltScheme() { + // Fields are individually mutable (backed by mutableStateOf) to support + // recomposition; verify that mutation is instance-local. + val mutated = darkScheme() + mutated.primary = CloudStreamPalette.Ongoing + + val fresh = darkScheme() + assertEquals(CloudStreamPalette.Primary, fresh.primary) + assertEquals(CloudStreamPalette.Ongoing, mutated.primary) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamPaletteTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamPaletteTest.kt new file mode 100644 index 00000000000..04c3a4f16ba --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamPaletteTest.kt @@ -0,0 +1,57 @@ +package com.lagradost.cloudstream4.ui.theme + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class CloudStreamPaletteTest { + + @Test + fun primaryMatchesExpectedHexValue() { + assertEquals(Color(0xFF3D50FA), CloudStreamPalette.Primary) + } + + @Test + fun amoledBlackIsPureBlack() { + assertEquals(Color(0xFF000000), CloudStreamPalette.AmoledBlack) + } + + @Test + fun lightBlackBgIsPureWhite() { + assertEquals(Color(0xFFFFFFFF), CloudStreamPalette.LightBlackBg) + } + + @Test + fun amoledBlackAndAmoledNearBlackAreDistinct() { + assertNotEquals(CloudStreamPalette.AmoledBlack, CloudStreamPalette.AmoledNearBlack) + } + + @Test + fun darkBackgroundColorsAreDarkerThanLightBackgroundColors() { + // A rough sanity check that "dark" theme colors are actually darker + // (lower luminance) than their "light" theme counterparts. + fun luminance(color: Color) = (color.red + color.green + color.blue) / 3f + + assertTrue(luminance(CloudStreamPalette.DarkBlackBg) < luminance(CloudStreamPalette.LightBlackBg)) + assertTrue(luminance(CloudStreamPalette.DarkText) > luminance(CloudStreamPalette.LightText)) + } + + @Test + fun eachThemeDefinesADistinctBackgroundColor() { + val backgrounds = listOf( + CloudStreamPalette.DarkBlackBg, + CloudStreamPalette.AmoledBlack, + CloudStreamPalette.LightBlackBg, + CloudStreamPalette.DraculaBlackBg, + CloudStreamPalette.LavenderBlackBg, + CloudStreamPalette.SilentBlueBlackBg, + ) + assertEquals( + backgrounds.size, + backgrounds.toSet().size, + "Theme backgrounds should not collide: $backgrounds", + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamPrimaryColorTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamPrimaryColorTest.kt new file mode 100644 index 00000000000..0030a0391d6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamPrimaryColorTest.kt @@ -0,0 +1,48 @@ +package com.lagradost.cloudstream4.ui.theme + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CloudStreamPrimaryColorTest { + + @Test + fun normalMatchesTheCloudStreamPaletteAccentColor() { + assertEquals(CloudStreamPalette.Primary, CloudStreamPrimaryColor.NORMAL.color) + } + + @Test + fun dynamicAndDynamicTwoFallBackToTheSameDefaultColor() { + // These entries are placeholders whose real color is resolved dynamically + // at runtime (see resolveDynamicPrimaryColor / resolveDynamicSecondaryColor); + // their static enum color should just be a sane, matching fallback. + assertEquals(CloudStreamPrimaryColor.DYNAMIC.color, CloudStreamPrimaryColor.DYNAMIC_TWO.color) + assertEquals(CloudStreamPrimaryColor.NORMAL.color, CloudStreamPrimaryColor.DYNAMIC.color) + } + + @Test + fun everyEntryHasAFullyOpaqueColor() { + for (entry in CloudStreamPrimaryColor.entries) { + assertEquals(1f, entry.color.alpha, "Expected $entry to be fully opaque") + } + } + + @Test + fun containsExpectedNumberOfColorOptions() { + assertEquals(22, CloudStreamPrimaryColor.entries.size) + } + + @Test + fun whiteEntryIsActuallyWhite() { + val white = CloudStreamPrimaryColor.WHITE.color + assertEquals(1f, white.red) + assertEquals(1f, white.green) + assertEquals(1f, white.blue) + } + + @Test + fun noTwoEntriesShareTheSameName() { + val names = CloudStreamPrimaryColor.entries.map { it.name } + assertTrue(names.toSet().size == names.size) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamThemeModeTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamThemeModeTest.kt new file mode 100644 index 00000000000..cfe893d2e33 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamThemeModeTest.kt @@ -0,0 +1,43 @@ +package com.lagradost.cloudstream4.ui.theme + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CloudStreamThemeModeTest { + + @Test + fun containsExpectedNumberOfModes() { + assertEquals(8, CloudStreamThemeMode.entries.size) + } + + @Test + fun containsAllExpectedModesByName() { + val expected = setOf( + "Dark", + "Amoled", + "Light", + "Dracula", + "Lavender", + "SilentBlue", + "FollowSystem", + "Dynamic", + ) + + val actual = CloudStreamThemeMode.entries.map { it.name }.toSet() + assertEquals(expected, actual) + } + + @Test + fun noTwoModesShareAnOrdinal() { + val ordinals = CloudStreamThemeMode.entries.map { it.ordinal } + assertTrue(ordinals.toSet().size == ordinals.size) + } + + @Test + fun valueOfRoundTripsForEveryMode() { + for (mode in CloudStreamThemeMode.entries) { + assertEquals(mode, CloudStreamThemeMode.valueOf(mode.name)) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamThemeTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamThemeTest.kt new file mode 100644 index 00000000000..14c9e2b3516 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/ui/theme/CloudStreamThemeTest.kt @@ -0,0 +1,119 @@ +package com.lagradost.cloudstream4.ui.theme + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Verifies that [CloudStreamTheme] resolves the right [CloudStreamColorScheme] + * for each [CloudStreamThemeMode], and that explicit primary color overrides apply. + */ +@OptIn(ExperimentalTestApi::class) +class CloudStreamThemeTest { + + @Test + fun darkModeExposesADarkColorScheme() = runComposeUiTest { + lateinit var colors: CloudStreamColorScheme + setContent { + CloudStreamTheme(mode = CloudStreamThemeMode.Dark) { + colors = CloudStreamTheme.colors + } + } + waitForIdle() + + assertFalse(colors.isLight) + assertEquals(CloudStreamPalette.DarkBlackBg, colors.background) + } + + @Test + fun lightModeExposesALightColorScheme() = runComposeUiTest { + lateinit var colors: CloudStreamColorScheme + setContent { + CloudStreamTheme(mode = CloudStreamThemeMode.Light) { + colors = CloudStreamTheme.colors + } + } + waitForIdle() + + assertTrue(colors.isLight) + assertEquals(CloudStreamPalette.LightBlackBg, colors.background) + } + + @Test + fun amoledModeUsesPureBlackForEveryBackgroundLayer() = runComposeUiTest { + lateinit var colors: CloudStreamColorScheme + setContent { + CloudStreamTheme(mode = CloudStreamThemeMode.Amoled) { + colors = CloudStreamTheme.colors + } + } + waitForIdle() + + assertEquals(CloudStreamPalette.AmoledBlack, colors.background) + assertEquals(CloudStreamPalette.AmoledBlack, colors.surface) + assertEquals(CloudStreamPalette.AmoledBlack, colors.surfaceVariant) + assertEquals(CloudStreamPalette.AmoledBlack, colors.surfaceContainer) + } + + @Test + fun draculaModeExposesTheDraculaScheme() = runComposeUiTest { + lateinit var colors: CloudStreamColorScheme + setContent { + CloudStreamTheme(mode = CloudStreamThemeMode.Dracula) { + colors = CloudStreamTheme.colors + } + } + waitForIdle() + + assertEquals(CloudStreamPalette.DraculaBlackBg, colors.background) + assertFalse(colors.isLight) + } + + @Test + fun explicitPrimaryColorOverridesTheSchemeDefaultPrimary() = runComposeUiTest { + lateinit var colors: CloudStreamColorScheme + setContent { + CloudStreamTheme( + mode = CloudStreamThemeMode.Dark, + primaryColor = CloudStreamPrimaryColor.RED, + ) { + colors = CloudStreamTheme.colors + } + } + waitForIdle() + + assertEquals(CloudStreamPrimaryColor.RED.color, colors.primary) + } + + @Test + fun defaultPrimaryColorMatchesNormal() = runComposeUiTest { + lateinit var colors: CloudStreamColorScheme + setContent { + CloudStreamTheme(mode = CloudStreamThemeMode.Dark) { + colors = CloudStreamTheme.colors + } + } + waitForIdle() + + assertEquals(CloudStreamPrimaryColor.NORMAL.color, colors.primary) + } + + @Test + fun colorsAreProvidedThroughLocalCloudStreamColors() = runComposeUiTest { + lateinit var fromLocal: CloudStreamColorScheme + lateinit var fromObject: CloudStreamColorScheme + setContent { + CloudStreamTheme(mode = CloudStreamThemeMode.SilentBlue) { + fromLocal = LocalCloudStreamColors.current + fromObject = CloudStreamTheme.colors + } + } + waitForIdle() + + assertEquals(fromLocal.background, fromObject.background) + assertEquals(CloudStreamPalette.SilentBlueBlackBg, fromLocal.background) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/utils/DeviceLayoutTest.kt b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/utils/DeviceLayoutTest.kt new file mode 100644 index 00000000000..bc4d570ef35 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/lagradost/cloudstream4/utils/DeviceLayoutTest.kt @@ -0,0 +1,69 @@ +package com.lagradost.cloudstream4.utils + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DeviceLayoutTest { + + @Test + fun singleFlagMatchesItself() { + assertTrue((DeviceLayout.PHONE).and(DeviceLayout.PHONE)) + } + + @Test + fun singleFlagDoesNotMatchOtherFlag() { + assertFalse((DeviceLayout.PHONE).and(DeviceLayout.TV)) + } + + @Test + fun orCombinesTwoFlagsSoEitherMatches() { + val combined = DeviceLayout.PHONE or DeviceLayout.TV + assertTrue(combined.and(DeviceLayout.PHONE)) + assertTrue(combined.and(DeviceLayout.TV)) + } + + @Test + fun orCombinationDoesNotMatchAnUnrelatedFlag() { + val combined = DeviceLayout.PHONE or DeviceLayout.TV + assertFalse(combined.and(DeviceLayout.EMULATOR)) + assertFalse(combined.and(DeviceLayout.COMPUTER)) + } + + @Test + fun orIsCommutative() { + val a = DeviceLayout.PHONE or DeviceLayout.COMPUTER + val b = DeviceLayout.COMPUTER or DeviceLayout.PHONE + assertTrue(a.and(DeviceLayout.PHONE) == b.and(DeviceLayout.PHONE)) + assertTrue(a.and(DeviceLayout.COMPUTER) == b.and(DeviceLayout.COMPUTER)) + } + + @Test + fun combiningAllFourFlagsMatchesEachOne() { + val everything = + DeviceLayout.PHONE or DeviceLayout.TV or DeviceLayout.EMULATOR or DeviceLayout.COMPUTER + assertTrue(everything.and(DeviceLayout.PHONE)) + assertTrue(everything.and(DeviceLayout.TV)) + assertTrue(everything.and(DeviceLayout.EMULATOR)) + assertTrue(everything.and(DeviceLayout.COMPUTER)) + } + + @Test + fun isLayoutReflectsResolvedLayoutAfterUpdate() { + DeviceLayout.update() + // resolveLayout() always resolves to exactly one of these four flags, + // so at least one of them must be considered active. + val anyKnownLayout = + DeviceLayout.PHONE or DeviceLayout.TV or DeviceLayout.EMULATOR or DeviceLayout.COMPUTER + assertTrue(DeviceLayout.isLayout(anyKnownLayout)) + } + + @Test + fun isLandscapeDoesNotThrow() { + DeviceLayout.update() + // Just verifying this completes without throwing on every target, + // since the actual result is platform/environment dependent, + // which we might add tests for eventually but not for now. + DeviceLayout.isLandscape() + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/lagradost/cloudstream4/preferences/PreferenceKeysTest.kt b/composeApp/src/jvmTest/kotlin/com/lagradost/cloudstream4/preferences/PreferenceKeysTest.kt new file mode 100644 index 00000000000..f958823447c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/lagradost/cloudstream4/preferences/PreferenceKeysTest.kt @@ -0,0 +1,25 @@ +package com.lagradost.cloudstream4.preferences + +import kotlin.reflect.full.declaredMemberProperties +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * This is in jvmTest rather than commonTest so that we can + * use kotlin-reflect (JVM-only) rather than having to + * manually maintain two seperate lists for these. + * Preferences keys should be unique, so this is + * just to ensure that. + */ +class PreferenceKeysTest { + + @Test + fun allPreferenceKeysAreUnique() { + val keys = PreferenceKeys::class.declaredMemberProperties + .filter { it.returnType.classifier == String::class } + .map { it.getter.call() } + + assertTrue(keys.isNotEmpty(), "No preference keys found via reflection") + assertTrue(keys.toSet().size == keys.size, "Preference keys must not collide: $keys") + } +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 00000000000..c822695e0f0 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,154 @@ +config: + validation: true + +comments: + active: false + +complexity: + LongMethod: + active: false + LongParameterList: + active: false + TooManyFunctions: + active: false + CyclomaticComplexMethod: + active: false + +ktlint: + active: true + BackingPropertyNaming: + active: true + excludes: ['**/ui/icons/**'] + BlankLineBetweenWhenConditions: + active: false + severity: info + ClassSignature: + active: false + FunctionExpressionBody: + active: false + severity: info + FunctionSignature: + active: false + Indentation: + active: true + excludes: ['**/ui/icons/**'] + MultiLineIfElse: + active: false + severity: info + SpacingBetweenDeclarationsWithComments: + active: false + severity: info + TrailingCommaOnCallSite: + active: false + +style: + ForbiddenComment: + active: false + ForbiddenImport: + active: true + forbiddenImports: + - value: 'kotlin.jvm.JvmField' + reason: 'Use explicit backing properties instead.' + - value: 'java.util.*' + reason: 'Use Kotlin standard library APIs instead.' + MagicNumber: + active: false + MaxLineLength: + active: true + maxLineLength: 120 + SpacingAfterPackageAndImports: + active: true + UnusedImport: + active: true + UnusedPrivateFunction: + active: true + ignoreAnnotated: ['Preview'] + UnusedPrivateProperty: + active: true + WildcardImport: + active: true + excludeImports: [] + +potential-bugs: + active: true + +coroutines: + active: true + GlobalCoroutineUsage: + active: true + +naming: + active: true + FunctionNaming: + active: true + ignoreAnnotated: ['Composable'] + MatchingDeclarationName: + active: false + +Compose: + active: true + ComposableAnnotationNaming: + active: true + ComposableNaming: + active: true + ComposableParamOrder: + active: true + CompositionLocalAllowlist: + active: true + allowedCompositionLocals: LocalCloudStreamColors + CompositionLocalNaming: + active: true + ContentEmitterReturningValues: + active: true + ContentTrailingLambda: + active: true + ContentSlotReused: + active: true + DefaultsVisibility: + active: true + LambdaParameterEventTrailing: + active: true + LambdaParameterInRestartableEffect: + active: true + Material2: + active: false + ModifierClickableOrder: + active: true + ModifierComposed: + active: true + ModifierMissing: + active: true + ModifierNaming: + active: true + ModifierNotUsedAtRoot: + active: true + ModifierReused: + active: true + ModifierWithoutDefault: + active: true + MultipleEmitters: + active: true + MutableParams: + active: true + MutableStateAutoboxing: + active: true + MutableStateParam: + active: true + ParameterNaming: + active: true + PreviewAnnotationNaming: + active: true + PreviewNaming: + active: true + PreviewPublic: + active: true + RememberMissing: + active: true + RememberContentMissing: + active: true + UnstableCollections: + active: true + ViewModelForwarding: + active: true + ViewModelInjection: + active: true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 028cc62b502..3577d51f46b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,11 +12,13 @@ coil = { strictly = "3.3.0" } # Later versions require jvmTarget 11 or later colorpicker = "6b46b49" composeMaterial3 = "1.11.0-alpha07" composeMultiplatform = "1.11.1" +composeRules = "0.6.2" conscryptAndroid = { strictly = "2.5.2" } # 2.5.3 crashes everything constraintlayout = "2.2.1" coreKtx = "1.18.0" cryptography = "0.6.0" desugar_jdk_libs_nio = "2.1.5" +detekt = "2.0.0-alpha.5" dokkaGradlePlugin = "2.2.0" espressoCore = "3.7.0" fragmentKtx = "1.8.9" @@ -81,8 +83,10 @@ colorpicker = { module = "com.github.recloudstream:color-picker-android", versio compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" } compose-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } +compose-rules-detekt = { module = "io.nlopez.compose.rules:detekt", version.ref = "composeRules" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscryptAndroid" } @@ -93,6 +97,7 @@ cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", ver cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography" } databinding = { module = "androidx.databinding:viewbinding", version.ref = "androidGradlePlugin" } desugar_jdk_libs_nio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs_nio" } +detekt-formatting = { module = "dev.detekt:detekt-rules-ktlint-wrapper", version.ref = "detekt" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } @@ -153,6 +158,7 @@ android-multiplatform-library = { id = "com.android.kotlin.multiplatform.library buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfigGradlePlugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinGradlePlugin" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +detekt = { id = "dev.detekt", version.ref = "detekt" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaGradlePlugin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm" , version.ref = "kotlinGradlePlugin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinGradlePlugin" }