diff --git a/android/build.gradle.kts b/android/build.gradle.kts index e3d0a528..c4dda1b8 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { testImplementation(libs.androidx.compose.ui) testImplementation(libs.junit) testImplementation(libs.kotlin.test) + testImplementation(libs.robolectric) detektPlugins(libs.detekt.formatting) } diff --git a/android/src/main/java/dev/appoutlet/some/android/resolver/UriResolver.kt b/android/src/main/java/dev/appoutlet/some/android/resolver/UriResolver.kt new file mode 100644 index 00000000..eb2ea22f --- /dev/null +++ b/android/src/main/java/dev/appoutlet/some/android/resolver/UriResolver.kt @@ -0,0 +1,121 @@ +package dev.appoutlet.some.android.resolver + +import android.net.Uri +import dev.appoutlet.some.android.strategy.UriStrategy +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 CONTENT_SCHEME = "content" +private const val FILE_SCHEME = "file" +private const val HTTPS_SCHEME = "https" +private const val SCHEME_SEPARATOR = "://" +private const val PATH_PREFIX = "/" +private const val QUERY_PREFIX = "?" +private const val QUERY_PAIR_SEPARATOR = "&" +private const val FRAGMENT_PREFIX = "#" + +private val ALLOWED_SCHEMES = listOf(CONTENT_SCHEME, FILE_SCHEME, HTTPS_SCHEME) + +private val URL_COMPATIBLE_CHARS = ('a'..'z') + ('0'..'9') + +private const val MIN_HOST_LENGTH = 3 +private const val MAX_HOST_LENGTH = 8 +private const val MIN_TLD_LENGTH = 2 +private const val MAX_TLD_LENGTH = 3 +private const val MIN_PATH_SEGMENTS = 1 +private const val MAX_PATH_SEGMENTS = 4 +private const val MIN_SEGMENT_LENGTH = 2 +private const val MAX_SEGMENT_LENGTH = 6 +private const val MIN_QUERY_PAIRS = 1 +private const val MAX_QUERY_PAIRS = 3 +private const val MIN_QUERY_KEY_LENGTH = 2 +private const val MAX_QUERY_KEY_LENGTH = 5 +private const val MIN_QUERY_VALUE_LENGTH = 2 +private const val MAX_QUERY_VALUE_LENGTH = 5 +private const val MIN_FRAGMENT_LENGTH = 2 +private const val MAX_FRAGMENT_LENGTH = 6 + +/** + * Resolves [Uri] types using the active [UriStrategy]. + * + * @param strategyProvider Provider of all configured generation strategies. + * @param random Random source used when generating random URI components. + */ +class UriResolver( + strategyProvider: StrategyProvider, + private val random: Random +) : Resolver { + private val uriStrategy = strategyProvider.get() ?: UriStrategy.default + + override fun canResolve(type: KType): Boolean = type == typeOf() + + override fun resolve(type: KType, chain: ResolverChain): Any { + val scheme = when (uriStrategy) { + is UriStrategy.Random -> ALLOWED_SCHEMES.random(random) + is UriStrategy.Content -> CONTENT_SCHEME + is UriStrategy.File -> FILE_SCHEME + is UriStrategy.Url -> HTTPS_SCHEME + } + + val host = generateHost() + val path = generatePath() + val query = if (random.nextBoolean()) generateQuery() else null + val fragment = if (random.nextBoolean()) generateFragment() else null + + val builder = StringBuilder() + .append(scheme) + .append(SCHEME_SEPARATOR) + .append(host) + .append(path) + + if (query != null) { + builder.append(QUERY_PREFIX).append(query) + } + + if (fragment != null) { + builder.append(FRAGMENT_PREFIX).append(fragment) + } + + return Uri.parse(builder.toString()) + } + + private fun generateHost(): String { + return if (uriStrategy is UriStrategy.Url) { + val host = urlCompatibleString(MIN_HOST_LENGTH, MAX_HOST_LENGTH) + val tld = urlCompatibleString(MIN_TLD_LENGTH, MAX_TLD_LENGTH) + "$host.$tld" + } else { + urlCompatibleString(MIN_HOST_LENGTH, MAX_HOST_LENGTH) + } + } + + private fun generatePath(): String { + val segments = random.nextInt(MIN_PATH_SEGMENTS, MAX_PATH_SEGMENTS) + return List(segments) { urlCompatibleString(MIN_SEGMENT_LENGTH, MAX_SEGMENT_LENGTH) } + .joinToString(prefix = PATH_PREFIX, separator = PATH_PREFIX) + } + + private fun generateQuery(): String { + val pairs = random.nextInt(MIN_QUERY_PAIRS, MAX_QUERY_PAIRS) + + return List(pairs) { + val key = urlCompatibleString(MIN_QUERY_KEY_LENGTH, MAX_QUERY_KEY_LENGTH) + val value = urlCompatibleString(MIN_QUERY_VALUE_LENGTH, MAX_QUERY_VALUE_LENGTH) + "$key=$value" + }.joinToString(QUERY_PAIR_SEPARATOR) + } + + private fun generateFragment(): String { + return urlCompatibleString(MIN_FRAGMENT_LENGTH, MAX_FRAGMENT_LENGTH) + } + + private fun urlCompatibleString(minLength: Int, maxLength: Int): String { + val length = random.nextInt(minLength, maxLength) + return List(length) { URL_COMPATIBLE_CHARS.random(random) }.joinToString("") + } +} diff --git a/android/src/main/java/dev/appoutlet/some/android/strategy/UriStrategy.kt b/android/src/main/java/dev/appoutlet/some/android/strategy/UriStrategy.kt new file mode 100644 index 00000000..6e3f28a6 --- /dev/null +++ b/android/src/main/java/dev/appoutlet/some/android/strategy/UriStrategy.kt @@ -0,0 +1,50 @@ +package dev.appoutlet.some.android.strategy + +import dev.appoutlet.some.config.Strategy + +/** + * Strategy for generating [android.net.Uri] values. + * + * Available strategies: + * - [Random] – generates URIs with any scheme from `[content, file, https]` (default) + * - [Content] – generates only `content://...` URIs + * - [File] – generates only `file://...` URIs + * - [Url] – generates only `https://...` URIs + * + * Example usage: + * ```kotlin + * someSetup { + * strategy(UriStrategy.Url) + * } + * ``` + */ +sealed interface UriStrategy : Strategy { + override val key get() = UriStrategy::class + + /** + * Generates URIs with a random scheme from `[content, file, https]`. + */ + data object Random : UriStrategy + + /** + * Generates only `content://...` URIs. + */ + data object Content : UriStrategy + + /** + * Generates only `file://...` URIs. + */ + data object File : UriStrategy + + /** + * Generates only `https://...` URIs. + */ + data object Url : UriStrategy + + companion object { + /** + * The default URI strategy. + */ + val default: UriStrategy get() = Random + } +} diff --git a/android/src/test/java/dev/appoutlet/some/android/resolver/UriResolverTest.kt b/android/src/test/java/dev/appoutlet/some/android/resolver/UriResolverTest.kt new file mode 100644 index 00000000..48a532e0 --- /dev/null +++ b/android/src/test/java/dev/appoutlet/some/android/resolver/UriResolverTest.kt @@ -0,0 +1,135 @@ +package dev.appoutlet.some.android.resolver + +import android.net.Uri +import dev.appoutlet.some.android.strategy.UriStrategy +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.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.random.Random +import kotlin.reflect.typeOf + +@RunWith(RobolectricTestRunner::class) +class UriResolverTest { + @Test + fun `UriResolver canResolve detects Uri type`() { + val resolver = UriResolver(DefaultStrategyProvider(), Random.Default) + assertTrue(resolver.canResolve(typeOf())) + } + + @Test + fun `UriResolver rejects non-Uri types`() { + val resolver = UriResolver(DefaultStrategyProvider(), Random.Default) + assertFalse(resolver.canResolve(typeOf())) + assertFalse(resolver.canResolve(typeOf())) + } + + @Test + fun `UriStrategy Random generates URIs with allowed schemes`() { + val resolver = UriResolver( + DefaultStrategyProvider(mapOf(UriStrategy::class to UriStrategy.Random)), + Random.Default + ) + + repeat(20) { + val result = resolver.resolve(typeOf(), testChain) as Uri + assertTrue( + "Expected scheme in [content, file, https] but got ${result.scheme}", + result.scheme in setOf("content", "file", "https") + ) + assertUriShape(result) + } + } + + @Test + fun `UriStrategy Url generates only https URIs`() { + val resolver = UriResolver( + DefaultStrategyProvider(mapOf(UriStrategy::class to UriStrategy.Url)), + Random.Default + ) + + repeat(10) { + val result = resolver.resolve(typeOf(), testChain) as Uri + assertEquals("https", result.scheme) + assertTrue( + "Generated URL $result does not match expected URL shape", + HTTPS_URL_REGEX.matches(result.toString()) + ) + } + } + + @Test + fun `UriStrategy Content generates only content URIs`() { + val resolver = UriResolver( + DefaultStrategyProvider(mapOf(UriStrategy::class to UriStrategy.Content)), + Random.Default + ) + + repeat(10) { + val result = resolver.resolve(typeOf(), testChain) as Uri + assertEquals("content", result.scheme) + assertUriShape(result) + } + } + + @Test + fun `UriStrategy File generates only file URIs`() { + val resolver = UriResolver( + DefaultStrategyProvider(mapOf(UriStrategy::class to UriStrategy.File)), + Random.Default + ) + + repeat(10) { + val result = resolver.resolve(typeOf(), testChain) as Uri + assertEquals("file", result.scheme) + assertUriShape(result) + } + } + + @Test + fun `Generated URIs are parseable by Uri parse`() { + val resolver = UriResolver(DefaultStrategyProvider(), Random.Default) + + repeat(20) { + val result = resolver.resolve(typeOf(), testChain) as Uri + val reparsed = Uri.parse(result.toString()) + assertNotNull(reparsed) + assertEquals(result.scheme, reparsed.scheme) + } + } + + @Test + fun `Default strategy is Random`() { + val resolver = UriResolver(DefaultStrategyProvider(), Random.Default) + val result = resolver.resolve(typeOf(), testChain) as Uri + assertTrue( + "Expected scheme in [content, file, https] but got ${result.scheme}", + result.scheme in setOf("content", "file", "https") + ) + assertUriShape(result) + } + + private fun assertUriShape(uri: Uri) { + assertNotNull(uri.scheme) + assertTrue("Host should not be empty", uri.host?.isNotEmpty() == true) + assertTrue("Path should not be empty", uri.path?.isNotEmpty() == true) + } + + companion object { + private val testChain = ResolverChain(emptyList(), NullableStrategy.NullOnCircularReference) + + private val HTTPS_URL_REGEX = ( + "^https://" + + "[a-z0-9]{3,8}\\.[a-z0-9]{2,3}" + + "(/[a-z0-9]{2,6})+" + + "(\\?[a-z0-9]{2,5}=[a-z0-9]{2,5}(&[a-z0-9]{2,5}=[a-z0-9]{2,5})*)?" + + "(#[a-z0-9]{2,6})?$" + ).toRegex() + } +} diff --git a/build.gradle.kts b/build.gradle.kts index a3cfdd57..18dd2ce8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.mavenPublish) apply false + alias(libs.plugins.detekt) apply false alias(libs.plugins.dokka) alias(libs.plugins.gitHooks) diff --git a/docs/android.md b/docs/android.md deleted file mode 100644 index aa954cae..00000000 --- a/docs/android.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -icon: lucide/smartphone ---- -# Android apps - -Use `dev.appoutlet:some-android` in Android projects. - -`some-android` re-exports the shared Some API from `some-core`, so Android users should depend on `some-android` and should not add `some-core` separately. - -## Installation - -=== "Gradle (Kotlin DSL)" - - ```kotlin - testImplementation("dev.appoutlet:some-android:{version}") - ``` - -=== "Gradle (Groovy)" - - ```groovy - testImplementation 'dev.appoutlet:some-android:{version}' - ``` - -## When to use it - -- Android unit tests -- Android libraries and applications that want the shared Some API - -## Platform constraints - -- Android `minSdk 24` -- Shared API behavior follows the same Kotlin/JVM constraints as `some-core` - -## Artifact-specific behavior - -- Re-exports the shared API from `some-core` -- Consumers should not add both `some-android` and `some-core` -- Does not currently add Android-only strategies or supported types beyond the shared API - -## Shared behavior - -The main Some documentation applies here as well: - -- [Getting Started](getting-started.md) -- [Strategies](strategies/index.md) -- [Supported Types](supported-types.md) -- [Type and Property Factories](custom-factories.md) -- [Custom Resolvers](custom-resolvers.md) diff --git a/docs/android/index.md b/docs/android/index.md new file mode 100644 index 00000000..bfab06bf --- /dev/null +++ b/docs/android/index.md @@ -0,0 +1,48 @@ +--- +icon: lucide/smartphone +--- + +# Android + +Use `dev.appoutlet:some-android` in Android projects when you want the shared Some API plus Android-specific type support. + +`some-android` re-exports `some-core`, so Android projects should depend on `some-android` only. + +## Installation + +=== "Gradle (Kotlin DSL)" + + ```kotlin + testImplementation("dev.appoutlet:some-android:{version}") + ``` + +=== "Gradle (Groovy)" + + ```groovy + testImplementation 'dev.appoutlet:some-android:{version}' + ``` + +!!! note "Do not add both Android and core" + + `some-android` already includes the shared API from `some-core`. + +## When to use it + +- Android unit tests and Robolectric tests +- Android libraries and apps that need generated `android.net.Uri` values +- Projects that want one dependency for both the shared Some API and Android-specific support + +## Supported Android types + +All shared types from [Supported Types](../supported-types.md) are available here too. + +Today, the Android module adds one Android-specific type: + +| Type | Usage | Notes | +|------|-------|-------| +| `android.net.Uri` | `some()` | See [Uri](uri.md) and [Uri Strategy](uri.md#uri-strategy) | + +## Platform constraints + +- Android `minSdk 24` +- Shared API behavior follows the same Kotlin/JVM constraints as `some-core` diff --git a/docs/android/uri.md b/docs/android/uri.md new file mode 100644 index 00000000..514c2e0c --- /dev/null +++ b/docs/android/uri.md @@ -0,0 +1,119 @@ +--- +icon: lucide/link +--- + +# Uri + +`some-android` includes a resolver for `android.net.Uri`. + +Once `some-android` is on your test classpath, `some()` works without custom factories or custom resolvers. + +!!! note "Android-only feature" + + `UriStrategy` is part of `some-android`. It is not available from `some-core`. + +## Basic usage + +```kotlin +import android.net.Uri +import dev.appoutlet.some.some + +val uri: Uri = some() +``` + +The generated value is a parseable `Uri` with a scheme, host, and path. Depending on the active strategy, it may also include a query string or fragment. + +## Uri strategy + +Use `UriStrategy` when the URI scheme matters for the code you are testing. + +| Strategy | Default | What it generates | Good for | +|----------|---------|-------------------|----------| +| `UriStrategy.Random` | Yes | Randomly chooses `content://`, `file://`, or `https://` | General tests that only need a valid `Uri` | +| `UriStrategy.Content` | No | Only `content://...` URIs | `ContentResolver`, providers, and content URIs | +| `UriStrategy.File` | No | Only `file://...` URIs | File URI handling | +| `UriStrategy.Url` | No | Only `https://...` URIs with a domain-like host | Web links and URL-style inputs | + +### Configure it per call + +```kotlin +import android.net.Uri +import dev.appoutlet.some.android.strategy.UriStrategy +import dev.appoutlet.some.some + +val uri = some { + strategy(UriStrategy.Url) +} +``` + +### Configure it once and reuse it + +```kotlin +import android.net.Uri +import dev.appoutlet.some.android.strategy.UriStrategy +import dev.appoutlet.some.someSetup + +val some = someSetup { + strategy(UriStrategy.Content) +} + +val avatarUri: Uri = some() +val documentUri: Uri = some() +``` + +## What the resolver generates + +- A scheme controlled by `UriStrategy` +- A non-empty host +- At least one path segment +- An optional query string +- An optional fragment + +When you use `UriStrategy.Url`, the host is generated in a domain-like format such as `example.ab`. + +## Picking the right strategy + +- Use `UriStrategy.Random` when your test only needs a valid `Uri` +- Use `UriStrategy.Content` when your code branches on `content` URIs +- Use `UriStrategy.File` when your code expects file URIs +- Use `UriStrategy.Url` when your code validates or handles `https` links + +## Examples + +=== "Random (default)" + + ```kotlin + val uri: Uri = some() + // Could be content://..., file://..., or https://... + ``` + +=== "Content" + + ```kotlin + val uri = some { + strategy(UriStrategy.Content) + } + // content://media/images + ``` + +=== "File" + + ```kotlin + val uri = some { + strategy(UriStrategy.File) + } + // file://downloads/report + ``` + +=== "Url" + + ```kotlin + val uri = some { + strategy(UriStrategy.Url) + } + // https://example.io/profile?id=42 + ``` + +If your test cares about the scheme, prefer an explicit strategy instead of asserting against the default random output. + +See [Android](index.md) for installation and the list of Android-specific supported types. diff --git a/docs/configuration.md b/docs/configuration.md index 453c397f..6100bbee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,4 +93,4 @@ Without a seed, values can vary between runs. - Use [Strategies](strategies/index.md) for the built-in defaults and strategy behavior. - Use [Supported Types](supported-types.md) for the shared type coverage. - Use [Type and Property Factories](custom-factories.md) for custom generation rules. -- Use [Android apps](android.md) or [Kotest integration](kotest.md) when you need platform-specific dependency guidance. +- Use [Android](android/index.md) or [Kotest integration](kotest.md) when you need platform-specific dependency guidance. diff --git a/docs/supported-types.md b/docs/supported-types.md index ec4081d2..d53d5fc0 100644 --- a/docs/supported-types.md +++ b/docs/supported-types.md @@ -7,6 +7,8 @@ Some resolves common Kotlin and Java types with zero configuration. Nullable var These supported types are part of the shared API consumed through both `some-core` and `some-android`. +For Android-specific support, see [Android](android/index.md). + For types not listed here, register a [custom factory](custom-factories.md) or ship a [custom resolver](custom-resolvers.md). ## Reference diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b82f5db..5be309ae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ junit = "4.13.2" kotlin = "2.4.0" kotest = "6.2.1" mavenPublish = "0.37.0" +robolectric = "4.16.1" [libraries] androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidxCompose" } @@ -19,7 +20,7 @@ kermit = { module = "co.touchlab:kermit", version = "2.1.0" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } - +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } [plugins] android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/zensical.toml b/zensical.toml index 7bd38c95..2ee65879 100644 --- a/zensical.toml +++ b/zensical.toml @@ -23,7 +23,10 @@ nav = [ "strategies/default-value-strategy.md" ] }, "supported-types.md", - "android.md", + { "Android" = [ + "android/index.md", + "android/uri.md" + ] }, "kotest.md", "custom-factories.md", "custom-resolvers.md",