diff --git a/.ignore b/.ignore new file mode 100644 index 00000000..d7e9669e --- /dev/null +++ b/.ignore @@ -0,0 +1 @@ +docs/reference diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 61eca0f3..2c6db4a9 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -3,17 +3,33 @@ icon: lucide/settings --- # Configuration -All configuration is done through `SomeConfig`. Every option has sensible defaults so you can start using `some()` immediately without any setup. +All configuration is done through `SomeConfigBuilder`. Every option has sensible defaults so you can start using `some()` immediately without any setup. -## The SomeConfig class +## The SomeConfigBuilder class + +Configuration lambdas (`someSetup {}`, `some {}`) receive a `SomeConfigBuilder` receiver with mutable properties. Calling `build()` produces an immutable `SomeConfig`. + +```kotlin +class SomeConfigBuilder { + var nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference + var stringStrategy: StringStrategy = StringStrategy.Random() + var collectionStrategy: CollectionStrategy = CollectionStrategy() + var seed: Long? = null + + fun register(kClass: KClass, factory: FixtureContext.() -> T) + fun build(): SomeConfig +} +``` + +The resulting `SomeConfig` is immutable: ```kotlin data class SomeConfig( - var nullableStrategy: NullableStrategy = NullableStrategy.Random(), - var stringStrategy: StringStrategy = StringStrategy.Random(), - var collectionStrategy: CollectionStrategy = CollectionStrategy(), - var seed: Long? = null, - val factories: MutableMap, FixtureContext.() -> Any?> = mutableMapOf(), + val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference, + val stringStrategy: StringStrategy = StringStrategy.Random(), + val collectionStrategy: CollectionStrategy = CollectionStrategy(), + val seed: Long? = null, + val factories: Map, FixtureContext.() -> Any?> = emptyMap(), ) ``` @@ -45,7 +61,7 @@ val user = some { } ``` -The inline form creates a copy of the default configuration, applies your overrides, and generates a single instance — without affecting the global defaults. +The inline form creates a new builder from the default configuration, applies your overrides, and generates a single instance — without affecting the global defaults. ### Aggregated configuration @@ -66,20 +82,11 @@ val result: Person = baseSome { val stillNeverNull: Person = baseSome() ``` -### Copying configurations - -`SomeConfig.copy()` creates a deep copy, including a fresh copy of the `factories` map. This prevents shared mutable state between config instances: - -```kotlin -val base = SomeConfig().apply { stringStrategy = StringStrategy.Uuid } -val custom = base.copy(seed = 999L) -``` - ## Default configuration | Property | Default | Description | Docs | |----------|---------|-------------|----------------------------------------------| -| `nullableStrategy` | `NullableStrategy.Random()` | 50% chance of `null` for nullable types | [NullableStrategy](nullable-strategy.md) | +| `nullableStrategy` | `NullableStrategy.NullOnCircularReference` | `null` for nullable circular references | [NullableStrategy](nullable-strategy.md) | | `stringStrategy` | `StringStrategy.Random()` | Random lowercase alphabetic, 8 characters | [StringStrategy](string-strategy.md) | | `collectionStrategy` | `CollectionStrategy()` | Collections of 1 to 5 elements | [CollectionStrategy](collection-strategy.md) | | `seed` | `null` | Uses non-deterministic `Random.Default` | — | @@ -101,7 +108,7 @@ Internally, `SomeConfig.buildRandom()` creates a seeded `kotlin.random.Random` i ## Custom factories -`SomeConfig` holds a `factories` map where you can register custom factory functions for specific types: +`SomeConfigBuilder` provides a `register` function to add custom factory functions for specific types: ```kotlin val some = someSetup { diff --git a/docs/configuration/nullable-strategy.md b/docs/configuration/nullable-strategy.md index e0739d7f..e2a1575b 100644 --- a/docs/configuration/nullable-strategy.md +++ b/docs/configuration/nullable-strategy.md @@ -2,8 +2,38 @@ Controls whether nullable types produce `null` or a concrete value during fixture generation. +The default is `NullableStrategy.NullOnCircularReference`, which prefers concrete values and only returns `null` +when a nullable type would otherwise create a circular reference. + ## Variants +### `NullOnCircularReference` + +Produces concrete values for nullable types unless resolving the value would create a circular reference. + +```kotlin +some { nullableStrategy = NullableStrategy.NullOnCircularReference } +``` + +This is the default strategy. + +```kotlin +data class Node(val next: Node?) + +val node = some() + +node.next // null +``` + +The nullable circular field is resolved as `null`, so generation stops instead of recursing forever. +Non-nullable circular references still throw `SomeCircularReferenceException` because no finite value can satisfy them: + +```kotlin +data class StrictNode(val next: StrictNode) + +some() // throws SomeCircularReferenceException +``` + ### `AlwaysNull` Always produces `null` for nullable types. @@ -38,10 +68,10 @@ some { nullableStrategy = NullableStrategy.Random(probability = 0.2) } // 80% chance of null (mostly null values) some { nullableStrategy = NullableStrategy.Random(probability = 0.8) } -// Never generate null (0% chance) — equivalent to NeverNull +// Never generate null (0% chance) - equivalent to NeverNull some { nullableStrategy = NullableStrategy.Random(probability = 0.0) } -// Always generate null (100% chance) — equivalent to AlwaysNull +// Always generate null (100% chance) - equivalent to AlwaysNull some { nullableStrategy = NullableStrategy.Random(probability = 1.0) } ``` @@ -53,13 +83,14 @@ some { nullableStrategy = NullableStrategy.Random(probability = 1.0) } | `0.5` | 50% chance (default) | | `1.0` | Always produces `null` (equivalent to `AlwaysNull`) | -The default probability is `0.5`, giving an equal chance of null or non-null values — useful for testing a mix of scenarios. +The default probability is `0.5`, giving an equal chance of null or non-null values. ## Summary table | Strategy | Behavior | |----------|----------| +| `NullOnCircularReference` | Produces concrete nullable values unless a circular reference requires `null` (default) | | `AlwaysNull` | Always produces `null` for nullable types | | `NeverNull` | Always produces a non-null value | -| `Random()` | 50% chance of `null` (default) | -| `Random(probability)` | Custom probability of `null` (0.0–1.0) | +| `Random()` | 50% chance of `null` | +| `Random(probability)` | Custom probability of `null` (0.0-1.0) | diff --git a/src/main/kotlin/dev/appoutlet/some/Some.kt b/src/main/kotlin/dev/appoutlet/some/Some.kt index a905c60a..e44508a8 100644 --- a/src/main/kotlin/dev/appoutlet/some/Some.kt +++ b/src/main/kotlin/dev/appoutlet/some/Some.kt @@ -1,44 +1,106 @@ package dev.appoutlet.some import dev.appoutlet.some.config.SomeConfig +import dev.appoutlet.some.config.SomeConfigBuilder import dev.appoutlet.some.core.ResolverChain import dev.appoutlet.some.core.TypeResolver import kotlin.random.Random import kotlin.reflect.typeOf +/** + * Fixture generator configured with a resolver chain and shared random source. + * + * Instances are created by [someSetup] and can be reused to generate multiple values with the same configuration. + * + * @param resolvers Ordered resolver list used to generate values. + * @param random Random source shared by resolvers created for this instance. + * @param config Configuration used by this generator. + */ class Some( val resolvers: List, val random: Random, val config: SomeConfig ) { + /** + * Generates a fixture value of type [T] using this instance's configuration. + * + * @param T Type to generate. + * @return Generated value of type [T]. + */ @Suppress("MemberNameEqualsClassName") inline fun some(): T { - val session = ResolverChain(resolvers) + val session = ResolverChain(resolvers, config.nullableStrategy) return session.resolve(typeOf()) as T } + /** + * Generates a fixture value of type [T]. + * + * Enables concise usage such as `some()` when `some` is a [Some] instance. + * + * @param T Type to generate. + * @return Generated value of type [T]. + */ inline operator fun invoke(): T = some() - inline operator fun invoke(noinline config: SomeConfig.() -> Unit = {}): T { - val aggregatedConfig = this.config.copy().apply(config) + /** + * Generates a fixture value of type [T] with per-call configuration overrides. + * + * Overrides are applied to a copy of this instance's configuration and do not mutate the original [Some]. + * + * @param T Type to generate. + * @param config Configuration overrides for this call. + * @return Generated value of type [T]. + */ + inline operator fun invoke(noinline config: SomeConfigBuilder.() -> Unit = {}): T { + val aggregatedConfig = this.config.toBuilder().apply(config).build() return Some(aggregatedConfig.buildResolvers(random), random, aggregatedConfig).some() } } -fun someSetup(config: SomeConfig.() -> Unit = {}): Some { - val someConfig = SomeConfig().apply(config) +/** + * Creates a reusable [Some] generator. + * + * Use this when multiple fixtures should share the same configuration and random source. + * + * @param config Configuration applied to the created generator. + * @return A configured [Some] instance. + */ +fun someSetup(config: SomeConfigBuilder.() -> Unit = {}): Some { + val someConfig = SomeConfigBuilder().apply(config).build() val random = someConfig.buildRandom() return Some(someConfig.buildResolvers(random), random, someConfig) } -val defaultResolvers: List by lazy { SomeConfig().buildResolvers() } +/** + * Lazily-created default configuration used by top-level [some]. + */ +val defaultConfig: SomeConfig by lazy { SomeConfig() } +/** + * Lazily-created default resolver chain used by top-level [some]. + */ +val defaultResolvers: List by lazy { defaultConfig.buildResolvers() } + +/** + * Generates a fixture value using the default configuration. + * + * @param T Type to generate. + * @return Generated value of type [T]. + */ inline fun some(): T { - val session = ResolverChain(defaultResolvers) + val session = ResolverChain(defaultResolvers, defaultConfig.nullableStrategy) return session.resolve(typeOf()) as T } -inline fun some(noinline config: SomeConfig.() -> Unit = {}): T { +/** + * Generates a fixture value using one-off configuration overrides. + * + * @param T Type to generate. + * @param config Configuration applied only to this generation call. + * @return Generated value of type [T]. + */ +inline fun some(noinline config: SomeConfigBuilder.() -> Unit = {}): T { val someSetup = someSetup(config) return someSetup.some() } diff --git a/src/main/kotlin/dev/appoutlet/some/config/NullableStrategy.kt b/src/main/kotlin/dev/appoutlet/some/config/NullableStrategy.kt index ba238107..f173e6cd 100644 --- a/src/main/kotlin/dev/appoutlet/some/config/NullableStrategy.kt +++ b/src/main/kotlin/dev/appoutlet/some/config/NullableStrategy.kt @@ -7,6 +7,7 @@ package dev.appoutlet.some.config * * ## Available Strategies * + * - [NullOnCircularReference] – (Default) Returns `null` when a circular reference is detected for a nullable type * - [AlwaysNull] – Always produces `null` for nullable types * - [NeverNull] – Always produces non-null values for nullable types * - [Random] – Produces `null` based on a configurable probability @@ -14,6 +15,9 @@ package dev.appoutlet.some.config * ## Example Usage * * ```kotlin + * // Default strategy + * some { nullableStrategy = NullableStrategy.NullOnCircularReference } + * * // Always generate null values * some { nullableStrategy = NullableStrategy.AlwaysNull } * @@ -31,6 +35,15 @@ package dev.appoutlet.some.config * ``` */ sealed interface NullableStrategy { + /** + * Returns `null` when a circular reference is detected for a nullable type. + * + * This is the default strategy. It allows generating recursive data structures + * by resolving the recursive field to `null` when it's nullable. + * Non-nullable circular references still throw an exception. + */ + object NullOnCircularReference : NullableStrategy + /** * Always returns `null` for nullable types. * diff --git a/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt index da3023a9..476d01f0 100644 --- a/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt +++ b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt @@ -35,38 +35,51 @@ import dev.appoutlet.some.resolver.ValueClassResolver import kotlin.random.Random import kotlin.reflect.KClass +/** + * Immutable configuration for customizing the behavior of [some][dev.appoutlet.some.some] fixture generation. + * + * Controls strategies for nullable types, strings, collections, random seeding, + * and custom factory registrations. + * + * Use [SomeConfigBuilder] to create instances via the DSL-style configuration lambdas, + * or construct directly. Use [toBuilder] to derive a new configuration from an existing one. + * + * @param nullableStrategy Strategy for handling nullable type resolution. + * Defaults to [NullableStrategy.NullOnCircularReference]. + * @param stringStrategy Strategy for generating string values. Defaults to [StringStrategy.Random]. + * @param collectionStrategy Strategy for generating collection sizes. Defaults to [CollectionStrategy]. + * @param seed Seed for reproducible random generation. If null, uses [Random.Default]. + * @param factories Map of custom factory functions registered for specific types. + */ data class SomeConfig( - var nullableStrategy: NullableStrategy = NullableStrategy.Random(), - var stringStrategy: StringStrategy = StringStrategy.Random(), - var collectionStrategy: CollectionStrategy = CollectionStrategy(), - var seed: Long? = null, - val factories: MutableMap, FixtureContext.() -> Any?> = mutableMapOf(), + val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference, + val stringStrategy: StringStrategy = StringStrategy.Random(), + val collectionStrategy: CollectionStrategy = CollectionStrategy(), + val seed: Long? = null, + val factories: Map, FixtureContext.() -> Any?> = emptyMap(), ) { - fun register(kClass: KClass, factory: FixtureContext.() -> T) { - factories[kClass] = factory - } - /** - * Creates a deep copy of this configuration, including a copy of the factories map - * to prevent shared mutable state between config instances. + * Creates a [SomeConfigBuilder] pre-populated with this configuration's values. + * + * Useful for deriving new configurations from an existing one without mutation. + * + * @return A [SomeConfigBuilder] containing this configuration's current state. */ - fun copy( - nullableStrategy: NullableStrategy = this.nullableStrategy, - stringStrategy: StringStrategy = this.stringStrategy, - collectionStrategy: CollectionStrategy = this.collectionStrategy, - seed: Long? = this.seed, - ): SomeConfig { - return SomeConfig( - nullableStrategy = nullableStrategy, - stringStrategy = stringStrategy, - collectionStrategy = collectionStrategy, - seed = seed, - factories = this.factories.toMutableMap() - ) + fun toBuilder(): SomeConfigBuilder = SomeConfigBuilder().apply { + nullableStrategy = this@SomeConfig.nullableStrategy + stringStrategy = this@SomeConfig.stringStrategy + collectionStrategy = this@SomeConfig.collectionStrategy + seed = this@SomeConfig.seed + populateFactories(this@SomeConfig.factories) } /** - * Builds the list of resolvers configured with this config's strategies and random instance. + * Creates the resolver chain used to generate fixture values. + * + * Resolver order defines precedence: the first resolver that supports a type is used. + * + * @param random Random source shared by resolvers that generate randomized values. + * @return The ordered [TypeResolver] list for this configuration. */ fun buildResolvers(random: Random = buildRandom()): List { return listOf( @@ -103,5 +116,10 @@ data class SomeConfig( ) } + /** + * Creates a [Random] instance for this configuration. + * + * @return [Random] seeded with [seed] if set, or [Random.Default] otherwise. + */ internal fun buildRandom(): Random = seed?.let { Random(it) } ?: Random.Default } diff --git a/src/main/kotlin/dev/appoutlet/some/config/SomeConfigBuilder.kt b/src/main/kotlin/dev/appoutlet/some/config/SomeConfigBuilder.kt new file mode 100644 index 00000000..62ad513c --- /dev/null +++ b/src/main/kotlin/dev/appoutlet/some/config/SomeConfigBuilder.kt @@ -0,0 +1,76 @@ +package dev.appoutlet.some.config + +import dev.appoutlet.some.core.FixtureContext +import kotlin.reflect.KClass + +/** + * Mutable builder for creating immutable [SomeConfig] instances. + * + * Used as the receiver in DSL-style configuration lambdas such as [dev.appoutlet.some.someSetup] + * and [dev.appoutlet.some.some] overrides. After configuration, call [build] to produce + * an immutable [SomeConfig]. + */ +class SomeConfigBuilder { + /** + * Strategy for handling nullable type resolution. + * Defaults to [NullableStrategy.NullOnCircularReference]. + */ + var nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference + + /** + * Strategy for generating string values. + * Defaults to [StringStrategy.Random]. + */ + var stringStrategy: StringStrategy = StringStrategy.Random() + + /** + * Strategy for generating collection sizes. + * Defaults to [CollectionStrategy] with a range of 1..5. + */ + var collectionStrategy: CollectionStrategy = CollectionStrategy() + + /** + * Seed for reproducible random generation. + * If null, uses [kotlin.random.Random.Default]. + */ + var seed: Long? = null + + private val _factories: MutableMap, FixtureContext.() -> Any?> = mutableMapOf() + + /** + * Registers a custom factory function for type [T]. + * + * Custom factories take priority over built-in resolvers. + * + * @param T The type this factory produces. + * @param kClass The [KClass] of the type to override. + * @param factory Lambda receiving a [FixtureContext] and returning a value of type [T]. + */ + fun register(kClass: KClass, factory: FixtureContext.() -> T) { + _factories[kClass] = factory + } + + /** + * Populates the builder's factory map with entries from an existing map. + * + * Used internally by [SomeConfig.toBuilder] to transfer factory registrations. + * + * @param factories Map of factory registrations to copy into this builder. + */ + internal fun populateFactories(factories: Map, FixtureContext.() -> Any?>) { + _factories.putAll(factories) + } + + /** + * Builds an immutable [SomeConfig] from the current state of this builder. + * + * @return A new [SomeConfig] instance with the configured values. + */ + fun build(): SomeConfig = SomeConfig( + nullableStrategy = nullableStrategy, + stringStrategy = stringStrategy, + collectionStrategy = collectionStrategy, + seed = seed, + factories = _factories.toMap() + ) +} diff --git a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt index ac1472f3..e9dff838 100644 --- a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt +++ b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt @@ -1,5 +1,6 @@ package dev.appoutlet.some.core +import dev.appoutlet.some.config.NullableStrategy import dev.appoutlet.some.exception.SomeCircularReferenceException import dev.appoutlet.some.exception.SomeUnresolvableTypeException import kotlin.reflect.KType @@ -9,9 +10,13 @@ import kotlin.reflect.KType * * This class maintains a mutable stack of types currently being resolved to detect circular references. * Each call to `some()` creates a new instance of this session to ensure thread safety. + * + * @param resolvers Ordered resolver list. The first resolver that supports a type is used. + * @param nullableStrategy Strategy used when a circular reference is detected for a nullable type. */ class ResolverChain( - val resolvers: List + val resolvers: List, + private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference, ) { private val resolutionStack = mutableListOf() @@ -22,9 +27,21 @@ class ResolverChain( val stack: List get() = resolutionStack.toList() + /** + * Resolves a value for [type] using the first matching resolver. + * + * The type is added to the resolution stack while it is being resolved and removed even if resolution fails. + * If the type would create a circular reference, [handleCircularReference] decides whether to return `null` + * or throw based on the configured [NullableStrategy]. + * + * @param type Type to resolve. + * @return A generated value for [type], or `null` when nullable circular references are allowed. + * @throws SomeCircularReferenceException when a circular reference cannot be represented as `null`. + * @throws SomeUnresolvableTypeException when no resolver supports [type]. + */ fun resolve(type: KType): Any? { - if (type in resolutionStack) { - throw SomeCircularReferenceException(type, resolutionStack.toList()) + if (detectCircularReference(type)) { + return handleCircularReference(type) } resolutionStack.add(type) @@ -41,4 +58,44 @@ class ResolverChain( resolutionStack.removeAt(resolutionStack.lastIndex) } } + + /** + * Returns whether [type] would repeat a classifier already on the resolution stack. + * + * Classifier comparison treats `T` and `T?` as the same logical type, which is required to detect recursive + * fields such as `data class Node(val next: Node?)`. The non-nullable case immediately after a nullable stack + * entry is not circular: that is the expected path where [dev.appoutlet.some.resolver.NullableResolver] + * unwraps `T?` into `T` before resolving the concrete value. + */ + private fun detectCircularReference(type: KType): Boolean { + val sameClassifierDetected = resolutionStack.any { it.classifier == type.classifier } + + return when { + sameClassifierDetected.not() -> false + type.isMarkedNullable -> true + resolutionStack.last().isMarkedNullable -> false + else -> true + } + } + + /** + * Handles a circular reference that was detected for [type]. + * + * Nullable circular references can be represented as `null` when the configured strategy allows it. Non-nullable + * circular references always throw because there is no finite value that satisfies the type. + * + * @param type Type that would create a circular reference. + * @return `null` when [type] is nullable and the nullable strategy allows null for circular references. + * @throws SomeCircularReferenceException when the circular reference cannot be resolved as `null`. + */ + private fun handleCircularReference(type: KType): Nothing? { + val strategyAllowsNull = nullableStrategy is NullableStrategy.AlwaysNull || + nullableStrategy is NullableStrategy.NullOnCircularReference + + if (type.isMarkedNullable && strategyAllowsNull) { + return null + } + + throw SomeCircularReferenceException(type, resolutionStack.toList()) + } } diff --git a/src/main/kotlin/dev/appoutlet/some/exception/SomeUnresolvableTypeException.kt b/src/main/kotlin/dev/appoutlet/some/exception/SomeUnresolvableTypeException.kt index 6549a7dd..86d72ad1 100644 --- a/src/main/kotlin/dev/appoutlet/some/exception/SomeUnresolvableTypeException.kt +++ b/src/main/kotlin/dev/appoutlet/some/exception/SomeUnresolvableTypeException.kt @@ -3,4 +3,4 @@ package dev.appoutlet.some.exception import kotlin.reflect.KType class SomeUnresolvableTypeException(type: KType) : - Exception("No resolver found for type $type. Register a custom factory using SomeConfig.register { ... }") + Exception("No resolver found for type $type. Register a custom factory using SomeConfigBuilder.register { ... }") diff --git a/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt b/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt index c9f04df6..5b064a84 100644 --- a/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt +++ b/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt @@ -10,18 +10,32 @@ import kotlin.reflect.full.createType /** * Resolves nullable Kotlin types according to the configured [NullableStrategy]. * + * - **NullOnCircularReference** – delegates to the chain to resolve normally (might be null if a cycle is detected). * - **AlwaysNull** – always returns `null`. - * - **NeverNull** – always resolves a non‑null value. + * - **NeverNull** – always resolves a non-null value. * - **Random** – returns `null` based on the strategy's probability. + * + * @param nullableStrategy Strategy used to decide whether nullable types should resolve to `null`. + * @param random Random source used by [NullableStrategy.Random]. */ class NullableResolver( - private val nullableStrategy: NullableStrategy = NullableStrategy.Random(), + private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference, private val random: Random ) : TypeResolver { override fun canResolve(type: KType): Boolean = type.isMarkedNullable + /** + * Resolves [type] according to [nullableStrategy]. + * + * Strategies that choose a concrete value resolve the non-null version of [type] through [chain]. + * + * @param type Nullable type to resolve. + * @param chain Resolver chain used to create non-null values when needed. + * @return `null` or a generated non-null value for [type]. + */ override fun resolve(type: KType, chain: ResolverChain): Any? { return when (nullableStrategy) { + is NullableStrategy.NullOnCircularReference -> createNonNullInstance(type, chain) is NullableStrategy.AlwaysNull -> null is NullableStrategy.NeverNull -> createNonNullInstance(type, chain) is NullableStrategy.Random -> { @@ -35,6 +49,12 @@ class NullableResolver( } } + /** + * Resolves the non-null version of [type] through [chain]. + * + * Circular references are still detected by [ResolverChain], which decides whether the current strategy allows + * the circular value to be represented as `null`. + */ private fun createNonNullInstance( type: KType, chain: ResolverChain @@ -43,6 +63,9 @@ class NullableResolver( return chain.resolve(nonNullType) } + /** + * Creates a copy of [type] with the nullable marker removed. + */ private fun createNonNullType(type: KType): KType { return type.classifier?.createType(type.arguments, false) ?: type } diff --git a/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt b/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt new file mode 100644 index 00000000..83e0c95f --- /dev/null +++ b/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt @@ -0,0 +1,108 @@ +package dev.appoutlet.some.integration + +import dev.appoutlet.some.config.NullableStrategy +import dev.appoutlet.some.exception.SomeCircularReferenceException +import dev.appoutlet.some.some +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull + +class CircularReferenceIntegrationTest { + data class Node(val next: Node?) + data class StrictNode(val next: StrictNode) + data class IndirectA(val b: IndirectB?) + data class IndirectB(val a: IndirectA?) + + @Test + fun `circular nullable field returns null under NullOnCircularReference by default`() { + val node: Node = some() + assertNull(node.next) + } + + @Test + fun `nullable top-level circular type returns instance with null circular field by default`() { + val node: Node? = some() + + val result = assertIs(node) + assertNull(result.next) + } + + @Test + fun `nullable top-level circular type returns instance with null circular field under NullOnCircularReference`() { + val node: Node? = some { + nullableStrategy = NullableStrategy.NullOnCircularReference + } + + val result = assertIs(node) + assertNull(result.next) + } + + @Test + fun `nullable top-level circular type throws under NeverNull strategy`() { + assertFailsWith { + some { + nullableStrategy = NullableStrategy.NeverNull + } + } + } + + @Test + fun `nullable top-level indirect circular type returns instance with null circular field by default`() { + val a: IndirectA? = some() + + val result = assertIs(a) + val b = assertIs(result.b) + assertNull(b.a) + } + + @Test + fun `nullable top-level strict circular type still throws by default`() { + assertFailsWith { + some() + } + } + + @Test + fun `circular non-nullable field still throws SomeCircularReferenceException`() { + assertFailsWith { + some() + } + } + + @Test + fun `indirect circular reference returns null for nullable field`() { + val a: IndirectA = some() + assertIs(a.b) + assertNull(a.b.a) + } + + @Test + fun `NeverNull strategy still throws on circular reference even if nullable`() { + assertFailsWith { + some { + nullableStrategy = NullableStrategy.NeverNull + } + } + } + + @Test + fun `AlwaysNull strategy handles circular reference by returning null`() { + // In this case NullableResolver will return null even before ResolverChain detects a cycle + val node: Node = some { + nullableStrategy = NullableStrategy.AlwaysNull + } + + assertNull(node.next) + } + + @Test + fun `Random strategy still throws on circular reference when it decides to resolve non-null`() { + // With probability 0, it's like NeverNull + assertFailsWith { + some { + nullableStrategy = NullableStrategy.Random(probability = 0.0) + } + } + } +} diff --git a/src/test/kotlin/dev/appoutlet/some/resolver/ArrayResolverTest.kt b/src/test/kotlin/dev/appoutlet/some/resolver/ArrayResolverTest.kt index 0c3d1ad5..0d7e6bae 100644 --- a/src/test/kotlin/dev/appoutlet/some/resolver/ArrayResolverTest.kt +++ b/src/test/kotlin/dev/appoutlet/some/resolver/ArrayResolverTest.kt @@ -15,9 +15,7 @@ import kotlin.test.assertTrue class ArrayResolverTest { @Test fun `ArrayResolver generates array with correct size`() { - val config = SomeConfig().apply { - collectionStrategy = CollectionStrategy(3..5) - } + val config = SomeConfig(collectionStrategy = CollectionStrategy(3..5)) val resolvers = config.buildResolvers() val resolver = ArrayResolver(CollectionStrategy(3..5), Random.Default) diff --git a/src/test/kotlin/dev/appoutlet/some/resolver/ListResolverTest.kt b/src/test/kotlin/dev/appoutlet/some/resolver/ListResolverTest.kt index 5bd8570a..df348673 100644 --- a/src/test/kotlin/dev/appoutlet/some/resolver/ListResolverTest.kt +++ b/src/test/kotlin/dev/appoutlet/some/resolver/ListResolverTest.kt @@ -14,9 +14,7 @@ import kotlin.test.assertTrue class ListResolverTest { @Test fun `ListResolver generates list with correct size`() { - val config = SomeConfig().apply { - collectionStrategy = CollectionStrategy(3..5) - } + val config = SomeConfig(collectionStrategy = CollectionStrategy(3..5)) val resolvers = config.buildResolvers() val resolver = ListResolver(CollectionStrategy(3..5), Random.Default) diff --git a/src/test/kotlin/dev/appoutlet/some/resolver/MapResolverTest.kt b/src/test/kotlin/dev/appoutlet/some/resolver/MapResolverTest.kt index 82999a55..921e8526 100644 --- a/src/test/kotlin/dev/appoutlet/some/resolver/MapResolverTest.kt +++ b/src/test/kotlin/dev/appoutlet/some/resolver/MapResolverTest.kt @@ -14,9 +14,7 @@ import kotlin.test.assertTrue class MapResolverTest { @Test fun `MapResolver generates map with correct size`() { - val config = SomeConfig().apply { - collectionStrategy = CollectionStrategy(2..4) - } + val config = SomeConfig(collectionStrategy = CollectionStrategy(2..4)) val resolvers = config.buildResolvers() val resolver = MapResolver(CollectionStrategy(2..4), Random.Default) diff --git a/src/test/kotlin/dev/appoutlet/some/resolver/NullableResolverTest.kt b/src/test/kotlin/dev/appoutlet/some/resolver/NullableResolverTest.kt index 94f41f94..6816f7d1 100644 --- a/src/test/kotlin/dev/appoutlet/some/resolver/NullableResolverTest.kt +++ b/src/test/kotlin/dev/appoutlet/some/resolver/NullableResolverTest.kt @@ -26,9 +26,7 @@ class NullableResolverTest { @Test fun `NullableResolver with NeverNull strategy generates non-null values`() { - val config = SomeConfig().apply { - nullableStrategy = NullableStrategy.NeverNull - } + val config = SomeConfig(nullableStrategy = NullableStrategy.NeverNull) val resolvers = config.buildResolvers() repeat(1000) { @@ -40,9 +38,7 @@ class NullableResolverTest { @Test fun `NullableResolver with Random strategy can return null or value`() { - val config = SomeConfig().apply { - nullableStrategy = NullableStrategy.Random() - } + val config = SomeConfig(nullableStrategy = NullableStrategy.Random()) val resolvers = config.buildResolvers() val results = (1..100).map { ResolverChain(resolvers).resolve(typeOf()) } @@ -53,9 +49,7 @@ class NullableResolverTest { @Test fun `NullableResolver with Random strategy and probability 0_0 always returns non-null`() { - val config = SomeConfig().apply { - nullableStrategy = NullableStrategy.Random(probability = 0.0) - } + val config = SomeConfig(nullableStrategy = NullableStrategy.Random(probability = 0.0)) val resolvers = config.buildResolvers() repeat(1000) { @@ -67,9 +61,7 @@ class NullableResolverTest { @Test fun `NullableResolver with Random strategy and probability 1_0 always returns null`() { - val config = SomeConfig().apply { - nullableStrategy = NullableStrategy.Random(probability = 1.0) - } + val config = SomeConfig(nullableStrategy = NullableStrategy.Random(probability = 1.0)) val resolvers = config.buildResolvers() repeat(1000) { diff --git a/src/test/kotlin/dev/appoutlet/some/resolver/SetResolverTest.kt b/src/test/kotlin/dev/appoutlet/some/resolver/SetResolverTest.kt index 9dbf8af6..994e3c35 100644 --- a/src/test/kotlin/dev/appoutlet/some/resolver/SetResolverTest.kt +++ b/src/test/kotlin/dev/appoutlet/some/resolver/SetResolverTest.kt @@ -15,9 +15,7 @@ import kotlin.test.assertTrue class SetResolverTest { @Test fun `SetResolver generates set with correct size`() { - val config = SomeConfig().apply { - collectionStrategy = CollectionStrategy(3..5) - } + val config = SomeConfig(collectionStrategy = CollectionStrategy(3..5)) val resolvers = config.buildResolvers() val resolver = SetResolver(CollectionStrategy(3..5), Random.Default) diff --git a/src/test/kotlin/dev/appoutlet/some/resolver/StringResolverTest.kt b/src/test/kotlin/dev/appoutlet/some/resolver/StringResolverTest.kt index 75f26332..37aa14db 100644 --- a/src/test/kotlin/dev/appoutlet/some/resolver/StringResolverTest.kt +++ b/src/test/kotlin/dev/appoutlet/some/resolver/StringResolverTest.kt @@ -71,9 +71,7 @@ class StringResolverTest { @Test fun `StringStrategy Random with custom length integrates with SomeConfig`() { - val config = SomeConfig().apply { - stringStrategy = StringStrategy.Random(length = 20) - } + val config = SomeConfig(stringStrategy = StringStrategy.Random(length = 20)) val resolvers = config.buildResolvers() val chain = ResolverChain(resolvers)