diff --git a/android/src/main/java/dev/appoutlet/some/android/AndroidResolverProvider.kt b/android/src/main/java/dev/appoutlet/some/android/AndroidResolverProvider.kt index 1a781cf9..77d90341 100644 --- a/android/src/main/java/dev/appoutlet/some/android/AndroidResolverProvider.kt +++ b/android/src/main/java/dev/appoutlet/some/android/AndroidResolverProvider.kt @@ -16,11 +16,12 @@ import dev.appoutlet.some.core.Resolver import dev.appoutlet.some.core.ResolverProvider import dev.appoutlet.some.core.StrategyProvider import kotlin.random.Random +import dev.appoutlet.some.android.resolver.compose.ColorResolver as ComposeColorResolver /** * Contributes Android-specific resolvers discovered through `ServiceLoader`. * - * The returned resolvers extend the shared `some-core` chain with Android-specific type support. + * The returned resolvers extend the shared `some-core` chain with Android framework and Compose type support. */ class AndroidResolverProvider : ResolverProvider { /** @@ -45,7 +46,8 @@ class AndroidResolverProvider : ResolverProvider { PairResolver(random), UserHandleResolver(random), ColorResolver(random), - AnnotatedStringResolver(random) + AnnotatedStringResolver(random), + ComposeColorResolver(strategyProvider, random), ) } } diff --git a/android/src/main/java/dev/appoutlet/some/android/resolver/compose/ColorResolver.kt b/android/src/main/java/dev/appoutlet/some/android/resolver/compose/ColorResolver.kt new file mode 100644 index 00000000..09c7a24e --- /dev/null +++ b/android/src/main/java/dev/appoutlet/some/android/resolver/compose/ColorResolver.kt @@ -0,0 +1,59 @@ +package dev.appoutlet.some.android.resolver.compose + +import androidx.compose.ui.graphics.Color +import dev.appoutlet.some.android.strategy.ColorStrategy +import dev.appoutlet.some.core.Resolver +import dev.appoutlet.some.core.ResolverChain +import dev.appoutlet.some.core.StrategyProvider +import dev.appoutlet.some.core.get +import kotlin.random.Random +import kotlin.reflect.KType +import kotlin.reflect.typeOf + +private const val HUE_MAX = 360f + +/** + * Resolves Compose [Color] values using the active [ColorStrategy]. + * + * @param strategyProvider Provider of all configured generation strategies. + * @param random Random source used when generating channel values. + */ +class ColorResolver( + strategyProvider: StrategyProvider, + private val random: Random +) : Resolver { + private val colorStrategy = strategyProvider.get() ?: ColorStrategy.default + + override fun canResolve(type: KType): Boolean = type == typeOf() + + override fun resolve(type: KType, chain: ResolverChain): Any { + return when (colorStrategy) { + is ColorStrategy.RandomArgb -> resolveRandomArgb() + is ColorStrategy.RandomHsl -> resolveRandomHsl() + is ColorStrategy.Palette -> colorStrategy.colors.random(random) + } + } + + private fun resolveRandomArgb(): Color { + return Color( + red = random.nextFloat(), + green = random.nextFloat(), + blue = random.nextFloat(), + alpha = random.nextFloat() + ) + } + + private fun resolveRandomHsl(): Color { + val hue = random.nextFloat() * HUE_MAX + val saturation = random.nextFloat() + val lightness = random.nextFloat() + val alpha = random.nextFloat() + + return Color.hsl( + hue = hue, + saturation = saturation, + lightness = lightness, + alpha = alpha + ) + } +} diff --git a/android/src/main/java/dev/appoutlet/some/android/strategy/ColorStrategy.kt b/android/src/main/java/dev/appoutlet/some/android/strategy/ColorStrategy.kt new file mode 100644 index 00000000..bc1d2474 --- /dev/null +++ b/android/src/main/java/dev/appoutlet/some/android/strategy/ColorStrategy.kt @@ -0,0 +1,51 @@ +package dev.appoutlet.some.android.strategy + +import androidx.compose.ui.graphics.Color +import dev.appoutlet.some.config.Strategy + +/** + * Strategy for generating Compose [Color] values. + * + * Available strategies: + * - [RandomArgb] - generates fully random red, green, blue, and alpha channels (default) + * - [RandomHsl] - generates colors from random hue, saturation, and lightness values + * - [Palette] - chooses from a fixed list of colors + * + * Example usage: + * ```kotlin + * someSetup { + * strategy(ColorStrategy.RandomHsl) + * } + * ``` + */ +sealed interface ColorStrategy : Strategy { + override val key get() = ColorStrategy::class + + /** + * Generates Compose colors with fully random ARGB channels. + */ + data object RandomArgb : ColorStrategy + + /** + * Generates Compose colors from random HSL values. + */ + data object RandomHsl : ColorStrategy + + /** + * Chooses colors from a fixed palette. + * + * @param colors Palette values eligible for generation. + */ + data class Palette(val colors: List) : ColorStrategy { + init { + require(colors.isNotEmpty()) { "Palette must contain at least one color" } + } + } + + companion object { + /** + * The default Compose color strategy. + */ + val default: ColorStrategy get() = RandomArgb + } +} diff --git a/android/src/test/java/dev/appoutlet/some/android/AndroidResolverProviderTest.kt b/android/src/test/java/dev/appoutlet/some/android/AndroidResolverProviderTest.kt index c974206b..c8dbd490 100644 --- a/android/src/test/java/dev/appoutlet/some/android/AndroidResolverProviderTest.kt +++ b/android/src/test/java/dev/appoutlet/some/android/AndroidResolverProviderTest.kt @@ -16,6 +16,7 @@ import dev.appoutlet.some.config.DefaultStrategyProvider import org.junit.Assert.assertEquals import org.junit.Test import kotlin.random.Random +import dev.appoutlet.some.android.resolver.compose.ColorResolver as ComposeColorResolver class AndroidResolverProviderTest { @Test @@ -35,7 +36,8 @@ class AndroidResolverProviderTest { PairResolver::class, UserHandleResolver::class, ColorResolver::class, - AnnotatedStringResolver::class + AnnotatedStringResolver::class, + ComposeColorResolver::class, ), resolvers.map { it::class } ) diff --git a/android/src/test/java/dev/appoutlet/some/android/AndroidServiceLoaderTest.kt b/android/src/test/java/dev/appoutlet/some/android/AndroidServiceLoaderTest.kt index 8299ea4d..2664e29f 100644 --- a/android/src/test/java/dev/appoutlet/some/android/AndroidServiceLoaderTest.kt +++ b/android/src/test/java/dev/appoutlet/some/android/AndroidServiceLoaderTest.kt @@ -5,10 +5,11 @@ import androidx.compose.ui.text.AnnotatedString import dev.appoutlet.some.some import org.junit.Assert.assertTrue import org.junit.Test +import androidx.compose.ui.graphics.Color as ComposeColor class AndroidServiceLoaderTest { @Test - fun `top-level some also works with AndroidTypeResolverProvider`() { + fun `top-level some also works with AndroidResolverProvider`() { val result: String = some() assertTrue("Generated string should not be empty", result.isNotEmpty()) } @@ -24,4 +25,10 @@ class AndroidServiceLoaderTest { val result: AnnotatedString = some() assertTrue("Generated AnnotatedString text should not be blank", result.text.isNotBlank()) } + + @Test + fun `top-level some resolves Compose Color through AndroidResolverProvider`() { + val result: ComposeColor = some() + assertTrue(result.alpha in 0f..1f) + } } diff --git a/android/src/test/java/dev/appoutlet/some/android/resolver/compose/ColorResolverTest.kt b/android/src/test/java/dev/appoutlet/some/android/resolver/compose/ColorResolverTest.kt new file mode 100644 index 00000000..f78fd45a --- /dev/null +++ b/android/src/test/java/dev/appoutlet/some/android/resolver/compose/ColorResolverTest.kt @@ -0,0 +1,80 @@ +package dev.appoutlet.some.android.resolver.compose + +import androidx.compose.ui.graphics.Color +import dev.appoutlet.some.android.strategy.ColorStrategy +import dev.appoutlet.some.config.DefaultStrategyProvider +import dev.appoutlet.some.config.NullableStrategy +import dev.appoutlet.some.core.ResolverChain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random +import kotlin.reflect.typeOf +import kotlin.test.assertNotNull + +class ColorResolverTest { + @Test + fun `ColorResolver generates Compose Color values in default RandomArgb mode`() { + val resolver = ColorResolver(DefaultStrategyProvider(), FloatSequenceRandom(0.1f, 0.2f, 0.3f, 0.4f)) + + val result = resolver.resolve(typeOf(), testChain) as Color + + assertEquals(Color(0.1f, 0.2f, 0.3f, 0.4f), result) + } + + @Test + fun `ColorResolver RandomHsl produces HSL converted colors`() { + val resolver = ColorResolver( + DefaultStrategyProvider(mapOf(ColorStrategy::class to ColorStrategy.RandomHsl)), + Random.Default + ) + + val result = resolver.resolve(typeOf(), testChain) as Color + + assertNotNull(result) + } + + @Test + fun `ColorResolver Palette only returns configured colors`() { + val expected = Color(0.3f, 0.4f, 0.5f, 0.6f) + val resolver = ColorResolver( + DefaultStrategyProvider(mapOf(ColorStrategy::class to ColorStrategy.Palette(listOf(expected)))), + Random.Default + ) + + repeat(10) { + val result = resolver.resolve(typeOf(), testChain) as Color + assertEquals(expected, result) + } + } + + @Test + fun `ColorResolver canResolve detects Compose Color type`() { + val resolver = ColorResolver(DefaultStrategyProvider(), Random.Default) + assertTrue(resolver.canResolve(typeOf())) + } + + @Test + fun `ColorResolver rejects non-Compose Color types`() { + val resolver = ColorResolver(DefaultStrategyProvider(), Random.Default) + assertFalse(resolver.canResolve(typeOf())) + assertFalse(resolver.canResolve(typeOf())) + assertFalse(resolver.canResolve(typeOf())) + } + + private class FloatSequenceRandom(vararg values: Float) : Random() { + private val values = values.toList() + private var index = 0 + + override fun nextBits(bitCount: Int): Int = 0 + + override fun nextFloat(): Float { + return values.getOrElse(index++) { error("No more float values configured for test random") } + } + } + + companion object { + private val testChain = ResolverChain(emptyList(), NullableStrategy.NullOnCircularReference) + } +} diff --git a/android/src/test/java/dev/appoutlet/some/android/strategy/ColorStrategyTest.kt b/android/src/test/java/dev/appoutlet/some/android/strategy/ColorStrategyTest.kt new file mode 100644 index 00000000..40ad41fc --- /dev/null +++ b/android/src/test/java/dev/appoutlet/some/android/strategy/ColorStrategyTest.kt @@ -0,0 +1,17 @@ +package dev.appoutlet.some.android.strategy + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Test + +class ColorStrategyTest { + @Test + fun `ColorStrategy default is RandomArgb`() { + assertEquals(ColorStrategy.RandomArgb, ColorStrategy.default) + } + + @Test(expected = IllegalArgumentException::class) + fun `ColorStrategy Palette rejects empty color lists`() { + ColorStrategy.Palette(emptyList()) + } +} diff --git a/docs/android/color.md b/docs/android/color.md new file mode 100644 index 00000000..198d0697 --- /dev/null +++ b/docs/android/color.md @@ -0,0 +1,114 @@ +--- +icon: lucide/palette +--- + +# Compose Color + +`some-android` includes a resolver for `androidx.compose.ui.graphics.Color`. + +Once `some-android` is on your test classpath, `some()` works without custom factories or custom resolvers. + +!!! note "Android-only feature" + + `ColorStrategy` is part of `some-android`. It is not available from `some-core`. + +## Basic usage + +```kotlin +import androidx.compose.ui.graphics.Color as ComposeColor +import dev.appoutlet.some.some + +val color: ComposeColor = some() +``` + +Use an import alias like `ComposeColor` when the same file also references `android.graphics.Color`. + +## Color strategy + +Use `ColorStrategy` when the distribution of generated Compose colors matters for the code you are testing. + +| Strategy | Default | What it generates | Good for | +|----------|---------|-------------------|----------| +| `ColorStrategy.RandomArgb` | Yes | Fully random red, green, blue, and alpha channels | General UI tests that only need any valid Compose color | +| `ColorStrategy.RandomHsl` | No | Random hue, saturation, and lightness converted to a Compose color | Tests that want a more color-model-aware distribution | +| `ColorStrategy.Palette` | No | A random value chosen from a fixed list of Compose colors | Design-system palettes, brand colors, and snapshot stability | + +### Configure it per call + +```kotlin +import androidx.compose.ui.graphics.Color as ComposeColor +import dev.appoutlet.some.android.strategy.ColorStrategy +import dev.appoutlet.some.some + +val accent = some { + strategy(ColorStrategy.RandomHsl) +} +``` + +### Configure it once and reuse it + +```kotlin +import androidx.compose.ui.graphics.Color as ComposeColor +import dev.appoutlet.some.android.strategy.ColorStrategy +import dev.appoutlet.some.someSetup + +val some = someSetup { + strategy( + ColorStrategy.Palette( + listOf( + ComposeColor(0.40f, 0.23f, 0.72f, 1f), + ComposeColor(0.15f, 0.61f, 0.84f, 1f), + ComposeColor(0.96f, 0.57f, 0.17f, 1f) + ) + ) + ) +} + +val primary: ComposeColor = some() +val secondary: ComposeColor = some() +``` + +## What the resolvers generate + +- `ColorStrategy.RandomArgb` randomizes red, green, blue, and alpha independently +- `ColorStrategy.RandomHsl` converts randomized hue, saturation, and lightness values into RGB channels +- `ColorStrategy.Palette` only returns colors from the configured palette + +## Picking the right strategy + +- Use `ColorStrategy.RandomArgb` when your test only needs any valid Compose color +- Use `ColorStrategy.RandomHsl` when hue or tonal distribution matters more than raw channel randomness +- Use `ColorStrategy.Palette` when your UI should stay inside a known set of allowed colors + +## Examples + +=== "RandomArgb (default)" + + ```kotlin + val color: ComposeColor = some() + ``` + +=== "RandomHsl" + + ```kotlin + val color = some { + strategy(ColorStrategy.RandomHsl) + } + ``` + +=== "Palette" + + ```kotlin + val color = some { + strategy( + ColorStrategy.Palette( + listOf( + ComposeColor(0.12f, 0.13f, 0.18f, 1f), + ComposeColor(0.87f, 0.33f, 0.29f, 1f) + ) + ) + ) + } + ``` + +See [Android](index.md) for installation and the list of Android-specific supported types. diff --git a/docs/android/index.md b/docs/android/index.md index f8102a73..f5dd1719 100644 --- a/docs/android/index.md +++ b/docs/android/index.md @@ -29,7 +29,7 @@ Use `dev.appoutlet:some-android` in Android projects when you want the shared So ## When to use it - Android unit tests and Robolectric tests -- Android libraries and apps that need generated `Uri`, `Rect`, `Point`, `Size`, or similar Android framework values +- Android libraries and apps that need generated `Uri`, `Rect`, `Point`, `Size`, Compose `Color`, or similar platform values - Projects that want one dependency for both the shared Some API and Android-specific support ## Supported Android types @@ -52,6 +52,7 @@ Today, the Android module adds these Android-specific types: | `android.os.UserHandle` | `some()` | API 24+; generated from a random non-system UID | | `android.graphics.Color` | `some()` | API 26+; generated from random RGB channel values | | `androidx.compose.ui.text.AnnotatedString` | `some()` | Non-blank plain text without span styles | +| `androidx.compose.ui.graphics.Color` | `some()` | See [Compose Color](color.md) and [Color Strategy](color.md#color-strategy) | ## Platform constraints diff --git a/docs/getting-started.md b/docs/getting-started.md index dbffbb7d..61642cbb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -17,7 +17,8 @@ dependencies { // Kotlin/JVM or Java tests testImplementation("dev.appoutlet:some-core:{version}") - // Android tests. Includes the shared Some API, so do not also add some-core. + // Android tests, including Android framework and Compose UI types. + // Includes the shared Some API, so do not also add some-core. testImplementation("dev.appoutlet:some-android:{version}") // Optional: Kotest property testing integration. diff --git a/zensical.toml b/zensical.toml index 9ff93647..88f6c995 100644 --- a/zensical.toml +++ b/zensical.toml @@ -26,7 +26,8 @@ nav = [ "supported-types.md", { "Android" = [ "android/index.md", - "android/uri.md" + "android/uri.md", + "android/color.md" ] }, "kotest.md", "custom-factories.md",