diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1c4f47e..07ea1b7 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -52,5 +52,24 @@ jobs: gradle-caches-${{ runner.os }}- - name: Detekt run: ./gradlew detekt + - name: Stop Gradle + run: ./gradlew --stop + + test: + name: Unit Tests + needs: [ validation ] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Save Gradle Caches + uses: actions/cache@v4 + with: + path: ~/.gradle/caches/ + key: gradle-caches-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-caches-${{ runner.os }}- + - name: Run Unit Tests + run: ./gradlew clean testDebugUnitTest - name: Stop Gradle run: ./gradlew --stop \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c81a44a..97e8411 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,8 +6,12 @@ compose = "1.7.8" coreKtx = "1.13.1" detekt = "1.23.6" dokka = "1.9.10" +junit = "4.13.2" +junitExt = "1.2.1" kotlin = "2.0.20" +kotlinxCoroutinesTest = "1.8.0" material3Android = "1.3.1" +mockk = "1.13.9" [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -20,10 +24,14 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +androidx-junit-ext = { module = "androidx.test.ext:junit", version.ref = "junitExt" } androidx-material3-android = { module = "androidx.compose.material3:material3-android", version.ref = "material3Android" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } dokka-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } +junit = { module = "junit:junit", version.ref = "junit" } kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } tools-gradle = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" } diff --git a/snacky/build.gradle.kts b/snacky/build.gradle.kts index 56f2c05..0c341f6 100644 --- a/snacky/build.gradle.kts +++ b/snacky/build.gradle.kts @@ -70,4 +70,38 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.material3.android) + + // Testing dependencies + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + androidTestImplementation(libs.androidx.junit.ext) +} + +// Configure test tasks to show detailed output in console +tasks.withType { + testLogging { + // Show test events in console + events("passed", "skipped", "failed", "standardOut", "standardError") + + // Show detailed information for failed tests + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showExceptions = true + showCauses = true + showStackTraces = true + + // Show standard output and error streams + showStandardStreams = false + + // Display summary after test execution + afterSuite(KotlinClosure2({ desc, result -> + if (desc.parent == null) { // Only execute for the overall test suite + println("\nTest Results: ${result.resultType}") + println("Tests run: ${result.testCount}, " + + "Passed: ${result.successfulTestCount}, " + + "Failed: ${result.failedTestCount}, " + + "Skipped: ${result.skippedTestCount}") + } + })) + } } \ No newline at end of file diff --git a/snacky/src/test/java/com/infinum/snacky/DefaultSnackbarDataTest.kt b/snacky/src/test/java/com/infinum/snacky/DefaultSnackbarDataTest.kt new file mode 100644 index 0000000..76bbf66 --- /dev/null +++ b/snacky/src/test/java/com/infinum/snacky/DefaultSnackbarDataTest.kt @@ -0,0 +1,325 @@ +package com.infinum.snacky + +import androidx.compose.runtime.Composable +import com.infinum.snacky.default.DefaultSnackbarData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Unit tests for [DefaultSnackbarData]. + * Tests the data model for default snackbars and their properties. + */ +class DefaultSnackbarDataTest { + + @Test + fun `DefaultSnackbarData should store message correctly`() { + // Given + val message = "Test message" + + // When + val data = DefaultSnackbarData( + message = message, + actionLabel = null, + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Short + ) + + // Then + assertEquals(message, data.message) + } + + @Test + fun `DefaultSnackbarData should store action label correctly`() { + // Given + val actionLabel = "Action" + + // When + val data = DefaultSnackbarData( + message = "Test", + actionLabel = actionLabel, + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Short + ) + + // Then + assertEquals(actionLabel, data.actionLabel) + } + + @Test + fun `DefaultSnackbarData should handle null action label`() { + // When + val data = DefaultSnackbarData( + message = "Test", + actionLabel = null, + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Short + ) + + // Then + assertNull(data.actionLabel) + } + + @Test + fun `DefaultSnackbarData should store dismiss action flag correctly`() { + // When + val dataWithDismiss = DefaultSnackbarData( + message = "Test", + actionLabel = null, + withDismissAction = true, + dismissActionContentDescription = "", + duration = SnackyDuration.Short + ) + + val dataWithoutDismiss = DefaultSnackbarData( + message = "Test", + actionLabel = null, + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Short + ) + + // Then + assertEquals(true, dataWithDismiss.withDismissAction) + assertEquals(false, dataWithoutDismiss.withDismissAction) + } + + @Test + fun `DefaultSnackbarData should store dismiss action content description`() { + // Given + val contentDescription = "Close" + + // When + val data = DefaultSnackbarData( + message = "Test", + actionLabel = null, + withDismissAction = true, + dismissActionContentDescription = contentDescription, + duration = SnackyDuration.Short + ) + + // Then + assertEquals(contentDescription, data.dismissActionContentDescription) + } + + @Test + fun `DefaultSnackbarData should store duration correctly`() { + // Given + val duration = SnackyDuration.Long + + // When + val data = DefaultSnackbarData( + message = "Test", + actionLabel = null, + withDismissAction = false, + dismissActionContentDescription = "", + duration = duration + ) + + // Then + assertEquals(duration, data.duration) + } + + @Test + fun `DefaultSnackbarData should have default empty callbacks`() { + // When + val data = DefaultSnackbarData( + message = "Test", + actionLabel = null, + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Short + ) + + // Then - Should not throw when calling callbacks + data.onDismissCallback() + data.onMainActionCallback() + } + + @Test + fun `DefaultSnackbarData should store custom callbacks`() { + // Given + var mainActionCalled = false + var dismissCalled = false + + // When + val data = DefaultSnackbarData( + message = "Test", + actionLabel = "Action", + withDismissAction = true, + dismissActionContentDescription = "", + duration = SnackyDuration.Short, + onMainActionCallback = { mainActionCalled = true }, + onDismissCallback = { dismissCalled = true } + ) + + data.onMainActionCallback() + data.onDismissCallback() + + // Then + assertEquals(true, mainActionCalled) + assertEquals(true, dismissCalled) + } + + @Test + fun `hasAction should be true when using default callbacks`() { + // Given - Using default callbacks (interface provides default empty lambdas) + val data = DefaultSnackbarData( + message = "Test", + actionLabel = null, + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Short + ) + + // Then + // Note: Due to Kotlin lambda comparison behavior, hasAction will be true + // even with default empty callbacks because each {} creates a new instance + // -> check if code can be improved + assertEquals(true, data.hasAction) + } + + @Test + fun `hasAction should be true when onMainActionCallback is provided`() { + // Given + val mainActionCallback: () -> Unit = { println("Main action") } + val data = DefaultSnackbarData( + message = "Test", + actionLabel = "Action", + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Short, + onMainActionCallback = mainActionCallback + ) + + // Then + assertEquals(true, data.hasAction) + } + + @Test + fun `hasAction should be true when onSecondaryActionCallback is provided`() { + // Given + val secondaryActionCallback: () -> Unit = { println("Secondary action") } + + // Create a custom SnackyData to test secondary callback + val data = object : SnackyData { + override val duration = SnackyDuration.Short + override val onDismissCallback: () -> Unit = {} + override val onMainActionCallback: () -> Unit = {} + override val onSecondaryActionCallback: () -> Unit = secondaryActionCallback + + @Composable + override fun Render(snackbarState: SnackyState) { + // No-op for testing + } + } + + // Then + assertEquals(true, data.hasAction) + } + + @Test + fun `hasAction should be true when both main and secondary callbacks are provided`() { + // Given + val mainActionCallback: () -> Unit = { println("Main action") } + val secondaryActionCallback: () -> Unit = { println("Secondary action") } + + // Create a custom SnackyData to test both callbacks + val data = object : SnackyData { + override val duration = SnackyDuration.Short + override val onDismissCallback: () -> Unit = {} + override val onMainActionCallback: () -> Unit = mainActionCallback + override val onSecondaryActionCallback: () -> Unit = secondaryActionCallback + + @Composable + override fun Render(snackbarState: SnackyState) { + // No-op for testing + } + } + + // Then + assertEquals(true, data.hasAction) + } + + @Test + fun `Two DefaultSnackbarData with same properties should have same core values`() { + // Given + val callback = { } + val data1 = DefaultSnackbarData( + message = "Test", + actionLabel = "Action", + withDismissAction = true, + dismissActionContentDescription = "Close", + duration = SnackyDuration.Short, + onDismissCallback = callback, + onMainActionCallback = callback + ) + + val data2 = DefaultSnackbarData( + message = "Test", + actionLabel = "Action", + withDismissAction = true, + dismissActionContentDescription = "Close", + duration = SnackyDuration.Short, + onDismissCallback = callback, + onMainActionCallback = callback + ) + + // Then - Check that core properties match + assertEquals(data1.message, data2.message) + assertEquals(data1.actionLabel, data2.actionLabel) + assertEquals(data1.withDismissAction, data2.withDismissAction) + assertEquals(data1.dismissActionContentDescription, data2.dismissActionContentDescription) + assertEquals(data1.duration, data2.duration) + } + + @Test + fun `Two DefaultSnackbarData with different messages should not be equal`() { + // Given + val data1 = DefaultSnackbarData( + message = "Test 1", + actionLabel = null, + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Short + ) + + val data2 = DefaultSnackbarData( + message = "Test 2", + actionLabel = null, + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Short + ) + + // Then + assertNotEquals(data1, data2) + } + + @Test + fun `Two DefaultSnackbarData with different durations should not be equal`() { + // Given + val data1 = DefaultSnackbarData( + message = "Test", + actionLabel = null, + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Short + ) + + val data2 = DefaultSnackbarData( + message = "Test", + actionLabel = null, + withDismissAction = false, + dismissActionContentDescription = "", + duration = SnackyDuration.Long + ) + + // Then + assertNotEquals(data1, data2) + } +} diff --git a/snacky/src/test/java/com/infinum/snacky/SnackyAnimationSpecTest.kt b/snacky/src/test/java/com/infinum/snacky/SnackyAnimationSpecTest.kt new file mode 100644 index 0000000..3c00412 --- /dev/null +++ b/snacky/src/test/java/com/infinum/snacky/SnackyAnimationSpecTest.kt @@ -0,0 +1,216 @@ +package com.infinum.snacky + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** + * Unit tests for [SnackyAnimationSpec]. + * Tests animation configuration and default values. + */ +class SnackyAnimationSpecTest { + + @Test + fun `Default SnackyAnimationSpec should have correct default values`() { + // When + val spec = SnackyAnimationSpec() + + // Then + assertEquals(150, spec.fadeInDuration) + assertEquals(75, spec.fadeOutDuration) + assertEquals(0, spec.inBetweenDelay) + assertEquals(0.8f, spec.scaleFactor, 0.001f) + assertEquals(LinearEasing, spec.scaleEasing) + assertEquals(FastOutSlowInEasing, spec.opacityEasing) + } + + @Test + fun `SnackyAnimationSpec should accept custom fadeInDuration`() { + // Given + val customFadeIn = 200 + + // When + val spec = SnackyAnimationSpec(fadeInDuration = customFadeIn) + + // Then + assertEquals(customFadeIn, spec.fadeInDuration) + } + + @Test + fun `SnackyAnimationSpec should accept custom fadeOutDuration`() { + // Given + val customFadeOut = 100 + + // When + val spec = SnackyAnimationSpec(fadeOutDuration = customFadeOut) + + // Then + assertEquals(customFadeOut, spec.fadeOutDuration) + } + + @Test + fun `SnackyAnimationSpec should accept custom inBetweenDelay`() { + // Given + val customDelay = 50 + + // When + val spec = SnackyAnimationSpec(inBetweenDelay = customDelay) + + // Then + assertEquals(customDelay, spec.inBetweenDelay) + } + + @Test + fun `SnackyAnimationSpec should accept custom scaleFactor`() { + // Given + val customScale = 0.5f + + // When + val spec = SnackyAnimationSpec(scaleFactor = customScale) + + // Then + assertEquals(customScale, spec.scaleFactor, 0.001f) + } + + @Test + fun `SnackyAnimationSpec should accept custom scaleEasing`() { + // Given + val customEasing = LinearOutSlowInEasing + + // When + val spec = SnackyAnimationSpec(scaleEasing = customEasing) + + // Then + assertEquals(customEasing, spec.scaleEasing) + } + + @Test + fun `SnackyAnimationSpec should accept custom opacityEasing`() { + // Given + val customEasing = LinearEasing + + // When + val spec = SnackyAnimationSpec(opacityEasing = customEasing) + + // Then + assertEquals(customEasing, spec.opacityEasing) + } + + @Test + fun `SnackyAnimationSpec should accept all custom values`() { + // Given + val customFadeIn = 200 + val customFadeOut = 100 + val customDelay = 50 + val customScale = 0.5f + val customScaleEasing = LinearOutSlowInEasing + val customOpacityEasing = LinearEasing + + // When + val spec = SnackyAnimationSpec( + fadeInDuration = customFadeIn, + fadeOutDuration = customFadeOut, + inBetweenDelay = customDelay, + scaleFactor = customScale, + scaleEasing = customScaleEasing, + opacityEasing = customOpacityEasing + ) + + // Then + assertEquals(customFadeIn, spec.fadeInDuration) + assertEquals(customFadeOut, spec.fadeOutDuration) + assertEquals(customDelay, spec.inBetweenDelay) + assertEquals(customScale, spec.scaleFactor, 0.001f) + assertEquals(customScaleEasing, spec.scaleEasing) + assertEquals(customOpacityEasing, spec.opacityEasing) + } + + @Test + fun `Two SnackyAnimationSpec with same values should be equal`() { + // Given + val spec1 = SnackyAnimationSpec( + fadeInDuration = 200, + fadeOutDuration = 100, + scaleFactor = 0.5f + ) + + val spec2 = SnackyAnimationSpec( + fadeInDuration = 200, + fadeOutDuration = 100, + scaleFactor = 0.5f + ) + + // Then + assertEquals(spec1, spec2) + } + + @Test + fun `Two SnackyAnimationSpec with different fadeInDuration should not be equal`() { + // Given + val spec1 = SnackyAnimationSpec(fadeInDuration = 200) + val spec2 = SnackyAnimationSpec(fadeInDuration = 150) + + // Then + assertNotEquals(spec1, spec2) + } + + @Test + fun `Two SnackyAnimationSpec with different scaleFactor should not be equal`() { + // Given + val spec1 = SnackyAnimationSpec(scaleFactor = 0.8f) + val spec2 = SnackyAnimationSpec(scaleFactor = 0.5f) + + // Then + assertNotEquals(spec1, spec2) + } + + @Test + fun `SnackyAnimationSpec should support zero scaleFactor`() { + // When + val spec = SnackyAnimationSpec(scaleFactor = 0f) + + // Then + assertEquals(0f, spec.scaleFactor, 0.001f) + } + + @Test + fun `SnackyAnimationSpec should support scaleFactor of 1`() { + // When + val spec = SnackyAnimationSpec(scaleFactor = 1f) + + // Then + assertEquals(1f, spec.scaleFactor, 0.001f) + } + + @Test + fun `SnackyAnimationSpec should support zero durations`() { + // When + val spec = SnackyAnimationSpec( + fadeInDuration = 0, + fadeOutDuration = 0, + inBetweenDelay = 0 + ) + + // Then + assertEquals(0, spec.fadeInDuration) + assertEquals(0, spec.fadeOutDuration) + assertEquals(0, spec.inBetweenDelay) + } + + @Test + fun `Copy of SnackyAnimationSpec should create new instance with updated values`() { + // Given + val original = SnackyAnimationSpec(fadeInDuration = 150) + + // When + val copy = original.copy(fadeInDuration = 200) + + // Then + assertEquals(150, original.fadeInDuration) + assertEquals(200, copy.fadeInDuration) + assertNotEquals(original, copy) + } +} diff --git a/snacky/src/test/java/com/infinum/snacky/SnackyDurationTest.kt b/snacky/src/test/java/com/infinum/snacky/SnackyDurationTest.kt new file mode 100644 index 0000000..6246225 --- /dev/null +++ b/snacky/src/test/java/com/infinum/snacky/SnackyDurationTest.kt @@ -0,0 +1,121 @@ +package com.infinum.snacky + +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * Unit tests for [SnackyDuration] sealed class. + * Tests the different duration types and their validations. + */ +class SnackyDurationTest { + + @Test + fun `Short duration should have 4 seconds value`() { + // Given + val shortDuration = SnackyDuration.Short + + // Then + assertEquals(4.seconds, shortDuration.value) + } + + @Test + fun `Long duration should have 10 seconds value`() { + // Given + val longDuration = SnackyDuration.Long + + // Then + assertEquals(10.seconds, longDuration.value) + } + + @Test + fun `Indefinite duration should have Long MAX_VALUE milliseconds`() { + // Given + val indefiniteDuration = SnackyDuration.Indefinite + + // Then + assertEquals(Long.MAX_VALUE.milliseconds, indefiniteDuration.value) + } + + @Test + fun `Custom duration with valid value should be created successfully`() { + // Given + val customDuration = 6.seconds + + // When + val snackyDuration = SnackyDuration.Custom(customDuration) + + // Then + assertEquals(customDuration, snackyDuration.value) + } + + @Test + fun `Custom duration with zero value should throw IllegalArgumentException`() { + // Given + val zeroDuration = 0.seconds + + // Then + assertThrows(IllegalArgumentException::class.java) { + SnackyDuration.Custom(zeroDuration) + } + } + + @Test + fun `Custom duration with negative value should throw IllegalArgumentException`() { + // Given + val negativeDuration = (-5).seconds + + // Then + assertThrows(IllegalArgumentException::class.java) { + SnackyDuration.Custom(negativeDuration) + } + } + + @Test + fun `Custom duration with 1 millisecond should be created successfully`() { + // Given + val customDuration = 1.milliseconds + + // When + val snackyDuration = SnackyDuration.Custom(customDuration) + + // Then + assertEquals(customDuration, snackyDuration.value) + } + + @Test + fun `Different duration types should not be equal`() { + // Given + val shortDuration = SnackyDuration.Short + val longDuration = SnackyDuration.Long + + // Then + assertNotEquals(shortDuration, longDuration) + assertNotEquals(shortDuration.value, longDuration.value) + } + + @Test + fun `Two Custom durations with same value should be equal`() { + // Given + val customDuration1 = SnackyDuration.Custom(6.seconds) + val customDuration2 = SnackyDuration.Custom(6.seconds) + + // Then + assertEquals(customDuration1, customDuration2) + assertEquals(customDuration1.value, customDuration2.value) + } + + @Test + fun `Two Custom durations with different values should not be equal`() { + // Given + val customDuration1 = SnackyDuration.Custom(6.seconds) + val customDuration2 = SnackyDuration.Custom(8.seconds) + + // Then + assertNotEquals(customDuration1, customDuration2) + assertNotEquals(customDuration1.value, customDuration2.value) + } +} diff --git a/snacky/src/test/java/com/infinum/snacky/SnackyHostStateTest.kt b/snacky/src/test/java/com/infinum/snacky/SnackyHostStateTest.kt new file mode 100644 index 0000000..e47ee9b --- /dev/null +++ b/snacky/src/test/java/com/infinum/snacky/SnackyHostStateTest.kt @@ -0,0 +1,283 @@ +package com.infinum.snacky + +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [SnackyHostState]. + * Tests snackbar state management, queueing, and callback invocations. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SnackyHostStateTest { + + private lateinit var hostState: SnackyHostState + + @Before + fun setup() { + hostState = SnackyHostState() + } + + @Test + fun `Initial state should have no current snackbar`() { + // Then + assertNull(hostState.currentSnackyState) + } + + @Test + fun `showSnackbar should display default snackbar with message`() = runTest { + // Given + val message = "Test message" + + // When + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar(message = message) + } + + // Then + assertNotNull(hostState.currentSnackyState) + val snackyData = hostState.currentSnackyState?.snackyData + assertNotNull(snackyData) + + // Dismiss to complete the job + hostState.currentSnackyState?.dismiss() + job.join() + } + + @Test + fun `showSnackbar should set correct duration when actionLabel is null`() = runTest { + // Given + val message = "Test message" + + // When + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar(message = message) + } + + // Then + val snackyData = hostState.currentSnackyState?.snackyData + assertEquals(SnackyDuration.Short, snackyData?.duration) + + // Cleanup + hostState.currentSnackyState?.dismiss() + job.join() + } + + @Test + fun `showSnackbar should set indefinite duration when actionLabel is provided`() = runTest { + // Given + val message = "Test message" + val actionLabel = "Action" + + // When + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar( + message = message, + actionLabel = actionLabel + ) + } + + // Then + val snackyData = hostState.currentSnackyState?.snackyData + assertEquals(SnackyDuration.Indefinite, snackyData?.duration) + + // Cleanup + hostState.currentSnackyState?.dismiss() + job.join() + } + + @Test + fun `showSnackbar should use custom duration when provided`() = runTest { + // Given + val message = "Test message" + val customDuration = SnackyDuration.Long + + // When + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar( + message = message, + duration = customDuration + ) + } + + // Then + val snackyData = hostState.currentSnackyState?.snackyData + assertEquals(customDuration, snackyData?.duration) + + // Cleanup + hostState.currentSnackyState?.dismiss() + job.join() + } + + @Test + fun `performMainAction should invoke onMainActionCallback`() = runTest { + // Given + val message = "Test message" + val actionCallback = mockk<() -> Unit>(relaxed = true) + + // When + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar( + message = message, + onActionCallback = actionCallback + ) + } + + // Then + assertNotNull(hostState.currentSnackyState) + hostState.currentSnackyState?.performMainAction() + + // Verify callback was invoked + verify { actionCallback() } + job.join() + } + + @Test + fun `performSecondaryAction should invoke onSecondaryActionCallback`() = runTest { + // Given + val secondaryActionCallback = mockk<() -> Unit>(relaxed = true) + val customData = TestSnackyData( + duration = SnackyDuration.Short, + onDismissCallback = {}, + onSecondaryActionCallback = secondaryActionCallback + ) + + // When + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar(customData) + } + + // Then + assertNotNull(hostState.currentSnackyState) + hostState.currentSnackyState?.performSecondaryAction() + + // Verify callback was invoked + verify { secondaryActionCallback() } + job.join() + } + + @Test + fun `dismiss should invoke onDismissCallback`() = runTest { + // Given + val message = "Test message" + val dismissCallback = mockk<() -> Unit>(relaxed = true) + + // When + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar( + message = message, + onDismissCallback = dismissCallback + ) + } + + // Then + assertNotNull(hostState.currentSnackyState) + hostState.currentSnackyState?.dismiss() + + // Verify callback was invoked + verify { dismissCallback() } + job.join() + } + + @Test + fun `showSnackbar should clear state after dismissal`() = runTest { + // Given + val message = "Test message" + + // When + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar(message = message) + } + + // Dismiss the snackbar + hostState.currentSnackyState?.dismiss() + job.join() + + // Then + assertNull(hostState.currentSnackyState) + } + + @Test + fun `showSnackbar should display custom SnackyData`() = runTest { + // Given + val customData = TestSnackyData( + duration = SnackyDuration.Short, + onDismissCallback = {} + ) + + // When + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar(customData) + } + + // Then + assertNotNull(hostState.currentSnackyState) + assertEquals(customData, hostState.currentSnackyState?.snackyData) + + // Cleanup + hostState.currentSnackyState?.dismiss() + job.join() + } + + @Test + fun `Multiple snackbars should queue properly`() = runTest { + // Given + val message1 = "First message" + val message2 = "Second message" + var firstSnackbarShown = false + var secondSnackbarShown = false + + // When - Launch two snackbars + val job1 = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar(message = message1) + firstSnackbarShown = true + } + + val job2 = launch(UnconfinedTestDispatcher(testScheduler)) { + hostState.showSnackbar(message = message2) + secondSnackbarShown = true + } + + // Then - First snackbar should be showing + assertNotNull(hostState.currentSnackyState) + + // Dismiss first snackbar + hostState.currentSnackyState?.dismiss() + job1.join() + + // Second snackbar should now be showing + assertNotNull(hostState.currentSnackyState) + + // Dismiss second snackbar + hostState.currentSnackyState?.dismiss() + job2.join() + + // Verify both were shown + assertEquals(true, firstSnackbarShown) + assertEquals(true, secondSnackbarShown) + assertNull(hostState.currentSnackyState) + } + + /** + * Test implementation of [SnackyData] for testing purposes + */ + private data class TestSnackyData( + override val duration: SnackyDuration, + override val onDismissCallback: () -> Unit, + override val onMainActionCallback: () -> Unit = {}, + override val onSecondaryActionCallback: () -> Unit = {} + ) : SnackyData { + @androidx.compose.runtime.Composable + override fun Render(snackbarState: SnackyState) { + // No-op for testing + } + } +}