From f41266d902e60bbb604df9c4751f2ddf9678952f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 19:06:43 +0000 Subject: [PATCH 1/9] feat: add NullOnCircularReference strategy as default - Added NullOnCircularReference to NullableStrategy - Updated ResolverChain to break cycles for nullable fields when using this strategy - Set NullOnCircularReference as the default strategy in SomeConfig - Updated NullableResolver and Some.kt to support the new strategy - Added CircularReferenceIntegrationTest and updated existing tests Co-authored-by: MessiasLima <10220064+MessiasLima@users.noreply.github.com> --- src/main/kotlin/dev/appoutlet/some/Some.kt | 7 +- .../appoutlet/some/config/NullableStrategy.kt | 13 ++++ .../dev/appoutlet/some/config/SomeConfig.kt | 2 +- .../dev/appoutlet/some/core/ResolverChain.kt | 19 +++++- .../some/resolver/NullableResolver.kt | 4 +- .../CircularReferenceIntegrationTest.kt | 64 +++++++++++++++++++ 6 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt diff --git a/src/main/kotlin/dev/appoutlet/some/Some.kt b/src/main/kotlin/dev/appoutlet/some/Some.kt index a905c60a..2880dcda 100644 --- a/src/main/kotlin/dev/appoutlet/some/Some.kt +++ b/src/main/kotlin/dev/appoutlet/some/Some.kt @@ -13,7 +13,7 @@ class Some( ) { @Suppress("MemberNameEqualsClassName") inline fun some(): T { - val session = ResolverChain(resolvers) + val session = ResolverChain(resolvers, config.nullableStrategy) return session.resolve(typeOf()) as T } @@ -31,10 +31,11 @@ fun someSetup(config: SomeConfig.() -> Unit = {}): Some { return Some(someConfig.buildResolvers(random), random, someConfig) } -val defaultResolvers: List by lazy { SomeConfig().buildResolvers() } +val defaultConfig: SomeConfig by lazy { SomeConfig() } +val defaultResolvers: List by lazy { defaultConfig.buildResolvers() } inline fun some(): T { - val session = ResolverChain(defaultResolvers) + val session = ResolverChain(defaultResolvers, defaultConfig.nullableStrategy) return session.resolve(typeOf()) as T } 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..76cc0f11 100644 --- a/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt +++ b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt @@ -36,7 +36,7 @@ import kotlin.random.Random import kotlin.reflect.KClass data class SomeConfig( - var nullableStrategy: NullableStrategy = NullableStrategy.Random(), + var nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference, var stringStrategy: StringStrategy = StringStrategy.Random(), var collectionStrategy: CollectionStrategy = CollectionStrategy(), var seed: Long? = null, diff --git a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt index ac1472f3..5c3c30c6 100644 --- a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt +++ b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt @@ -1,8 +1,10 @@ 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 +import kotlin.reflect.full.createType /** * Resolution session that manages the type resolution chain and tracks circular dependencies. @@ -11,7 +13,8 @@ import kotlin.reflect.KType * Each call to `some()` creates a new instance of this session to ensure thread safety. */ class ResolverChain( - val resolvers: List + val resolvers: List, + private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference ) { private val resolutionStack = mutableListOf() @@ -27,6 +30,12 @@ class ResolverChain( throw SomeCircularReferenceException(type, resolutionStack.toList()) } + if (nullableStrategy is NullableStrategy.NullOnCircularReference && type.isMarkedNullable) { + if (isCircularIgnoringNullability(type)) { + return null + } + } + resolutionStack.add(type) try { @@ -41,4 +50,12 @@ class ResolverChain( resolutionStack.removeAt(resolutionStack.lastIndex) } } + + private fun isCircularIgnoringNullability(type: KType): Boolean { + val nonNullableType = type.classifier?.createType(type.arguments, false) ?: type + return resolutionStack.any { + val otherNonNullable = it.classifier?.createType(it.arguments, false) ?: it + otherNonNullable == nonNullableType + } + } } diff --git a/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt b/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt index c9f04df6..78221b07 100644 --- a/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt +++ b/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt @@ -10,18 +10,20 @@ 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. * - **Random** – returns `null` based on the strategy's probability. */ 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 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 -> { 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..6ccc07b3 --- /dev/null +++ b/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt @@ -0,0 +1,64 @@ +package dev.appoutlet.some.integration + +import dev.appoutlet.some.some +import dev.appoutlet.some.config.NullableStrategy +import dev.appoutlet.some.exception.SomeCircularReferenceException +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertFailsWith + +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 `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) + } + } + } +} From 550a3d84ca2f7846aef9e6d7937af14542513f40 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 19:15:47 +0000 Subject: [PATCH 2/9] feat: add NullOnCircularReference strategy as default - Added NullOnCircularReference to NullableStrategy - Updated ResolverChain to break cycles for nullable fields when using this strategy - Set NullOnCircularReference as the default strategy in SomeConfig - Updated NullableResolver and Some.kt to support the new strategy - Added CircularReferenceIntegrationTest with proper import ordering - Updated existing tests for ResolverChain changes Co-authored-by: MessiasLima <10220064+MessiasLima@users.noreply.github.com> --- .../some/integration/CircularReferenceIntegrationTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt b/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt index 6ccc07b3..53b42a53 100644 --- a/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt +++ b/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt @@ -1,12 +1,12 @@ package dev.appoutlet.some.integration -import dev.appoutlet.some.some 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 -import kotlin.test.assertFailsWith class CircularReferenceIntegrationTest { data class Node(val next: Node?) From 338ef46af8a20f99e19e943369ab8cb2654b8232 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Fri, 8 May 2026 14:57:01 +0100 Subject: [PATCH 3/9] docs: SomeConfig kdocs --- .../dev/appoutlet/some/config/SomeConfig.kt | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt index 76cc0f11..d0ebe118 100644 --- a/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt +++ b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt @@ -35,6 +35,18 @@ import dev.appoutlet.some.resolver.ValueClassResolver import kotlin.random.Random import kotlin.reflect.KClass +/** + * Configuration for customizing the behavior of [some] fixture generation. + * + * Controls strategies for nullable types, strings, collections, random seeding, + * and custom factory registrations. + * + * @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.NullOnCircularReference, var stringStrategy: StringStrategy = StringStrategy.Random(), @@ -47,8 +59,15 @@ data class SomeConfig( } /** - * Creates a deep copy of this configuration, including a copy of the factories map - * to prevent shared mutable state between config instances. + * Returns a copy of this configuration with an independent factories map. + * + * The strategy values are reused unless replacement values are provided. + * + * @param nullableStrategy Nullable strategy for the copied configuration. + * @param stringStrategy String strategy for the copied configuration. + * @param collectionStrategy Collection strategy for the copied configuration. + * @param seed Random seed for the copied configuration. + * @return A new [SomeConfig] containing the provided values and copied factory registrations. */ fun copy( nullableStrategy: NullableStrategy = this.nullableStrategy, @@ -66,7 +85,12 @@ data class SomeConfig( } /** - * 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( From 406f3b1d9a5a1d5adc3274b7758478a88a35b7e9 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Fri, 8 May 2026 15:07:06 +0100 Subject: [PATCH 4/9] feat: enhance circular reference handling --- .../dev/appoutlet/some/core/ResolverChain.kt | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt index 5c3c30c6..9c4f97b8 100644 --- a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt +++ b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt @@ -27,13 +27,7 @@ class ResolverChain( fun resolve(type: KType): Any? { if (type in resolutionStack) { - throw SomeCircularReferenceException(type, resolutionStack.toList()) - } - - if (nullableStrategy is NullableStrategy.NullOnCircularReference && type.isMarkedNullable) { - if (isCircularIgnoringNullability(type)) { - return null - } + return handleCircularReference(type) } resolutionStack.add(type) @@ -51,11 +45,11 @@ class ResolverChain( } } - private fun isCircularIgnoringNullability(type: KType): Boolean { - val nonNullableType = type.classifier?.createType(type.arguments, false) ?: type - return resolutionStack.any { - val otherNonNullable = it.classifier?.createType(it.arguments, false) ?: it - otherNonNullable == nonNullableType + private fun handleCircularReference(type: KType): Nothing? { + if (type.isMarkedNullable && nullableStrategy is NullableStrategy.NullOnCircularReference) { + return null } + + throw SomeCircularReferenceException(type, resolutionStack.toList()) } } From 421c7eb1c788737daba1e71c1deb0d2fa171e0ca Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Fri, 8 May 2026 15:25:22 +0100 Subject: [PATCH 5/9] fix: always null behaviour --- src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt | 7 +++++-- .../some/integration/CircularReferenceIntegrationTest.kt | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt index 9c4f97b8..579a3a4c 100644 --- a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt +++ b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt @@ -26,7 +26,7 @@ class ResolverChain( get() = resolutionStack.toList() fun resolve(type: KType): Any? { - if (type in resolutionStack) { + if (resolutionStack.any { it.classifier == type.classifier }) { return handleCircularReference(type) } @@ -46,7 +46,10 @@ class ResolverChain( } private fun handleCircularReference(type: KType): Nothing? { - if (type.isMarkedNullable && nullableStrategy is NullableStrategy.NullOnCircularReference) { + val strategyAllowsNull = nullableStrategy is NullableStrategy.AlwaysNull || + nullableStrategy is NullableStrategy.NullOnCircularReference + + if (type.isMarkedNullable && strategyAllowsNull) { return null } diff --git a/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt b/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt index 53b42a53..9a1759af 100644 --- a/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt +++ b/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt @@ -49,6 +49,7 @@ class CircularReferenceIntegrationTest { val node: Node = some { nullableStrategy = NullableStrategy.AlwaysNull } + assertNull(node.next) } From d92fd8b657130558d8a46a39a840775af77db5fc Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Fri, 8 May 2026 15:38:48 +0100 Subject: [PATCH 6/9] chore: ignore reference folder --- .ignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .ignore diff --git a/.ignore b/.ignore new file mode 100644 index 00000000..2c76037f --- /dev/null +++ b/.ignore @@ -0,0 +1 @@ +docs/reference \ No newline at end of file From 957b7818d3c6e7f8aafc661a682043490429339f Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Fri, 8 May 2026 16:23:12 +0100 Subject: [PATCH 7/9] refactor: enhance circular reference detection --- .../kotlin/dev/appoutlet/some/core/ResolverChain.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt index 579a3a4c..4de315da 100644 --- a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt +++ b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt @@ -26,7 +26,7 @@ class ResolverChain( get() = resolutionStack.toList() fun resolve(type: KType): Any? { - if (resolutionStack.any { it.classifier == type.classifier }) { + if (detectCircularReference(type)) { return handleCircularReference(type) } @@ -45,6 +45,17 @@ class ResolverChain( } } + 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 + } + } + private fun handleCircularReference(type: KType): Nothing? { val strategyAllowsNull = nullableStrategy is NullableStrategy.AlwaysNull || nullableStrategy is NullableStrategy.NullOnCircularReference From c478326d9a6441e4d2630bc5d682df59d0c8efea Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Fri, 8 May 2026 16:44:02 +0100 Subject: [PATCH 8/9] docs: add kdocs --- docs/configuration/index.md | 6 +- docs/configuration/nullable-strategy.md | 41 +++++++++++-- src/main/kotlin/dev/appoutlet/some/Some.kt | 60 +++++++++++++++++++ .../dev/appoutlet/some/config/SomeConfig.kt | 3 +- .../dev/appoutlet/some/core/ResolverChain.kt | 40 +++++++++++-- .../some/resolver/NullableResolver.kt | 23 ++++++- .../CircularReferenceIntegrationTest.kt | 43 +++++++++++++ 7 files changed, 202 insertions(+), 14 deletions(-) diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 61eca0f3..e17ad3bf 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -9,7 +9,7 @@ All configuration is done through `SomeConfig`. Every option has sensible defaul ```kotlin data class SomeConfig( - var nullableStrategy: NullableStrategy = NullableStrategy.Random(), + var nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference, var stringStrategy: StringStrategy = StringStrategy.Random(), var collectionStrategy: CollectionStrategy = CollectionStrategy(), var seed: Long? = null, @@ -68,7 +68,7 @@ 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: +`SomeConfig.copy()` creates a new configuration with a fresh copy of the `factories` map. This prevents shared mutable factory registrations between config instances: ```kotlin val base = SomeConfig().apply { stringStrategy = StringStrategy.Uuid } @@ -79,7 +79,7 @@ val custom = base.copy(seed = 999L) | 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` | — | 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 2880dcda..8ab3ef73 100644 --- a/src/main/kotlin/dev/appoutlet/some/Some.kt +++ b/src/main/kotlin/dev/appoutlet/some/Some.kt @@ -6,39 +6,99 @@ 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, 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() + /** + * 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: SomeConfig.() -> Unit = {}): T { val aggregatedConfig = this.config.copy().apply(config) return Some(aggregatedConfig.buildResolvers(random), random, aggregatedConfig).some() } } +/** + * 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: SomeConfig.() -> Unit = {}): Some { val someConfig = SomeConfig().apply(config) val random = someConfig.buildRandom() return Some(someConfig.buildResolvers(random), random, someConfig) } +/** + * 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, defaultConfig.nullableStrategy) return session.resolve(typeOf()) as 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: SomeConfig.() -> Unit = {}): T { val someSetup = someSetup(config) return someSetup.some() diff --git a/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt index d0ebe118..abd688c2 100644 --- a/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt +++ b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt @@ -41,7 +41,8 @@ import kotlin.reflect.KClass * Controls strategies for nullable types, strings, collections, random seeding, * and custom factory registrations. * - * @param nullableStrategy Strategy for handling nullable type resolution. Defaults to [NullableStrategy.NullOnCircularReference]. + * @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]. diff --git a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt index 4de315da..e9dff838 100644 --- a/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt +++ b/src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt @@ -4,17 +4,19 @@ import dev.appoutlet.some.config.NullableStrategy import dev.appoutlet.some.exception.SomeCircularReferenceException import dev.appoutlet.some.exception.SomeUnresolvableTypeException import kotlin.reflect.KType -import kotlin.reflect.full.createType /** * Resolution session that manages the type resolution chain and tracks circular dependencies. * * 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, - private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference + private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference, ) { private val resolutionStack = mutableListOf() @@ -25,6 +27,18 @@ 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 (detectCircularReference(type)) { return handleCircularReference(type) @@ -45,6 +59,14 @@ class ResolverChain( } } + /** + * 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 } @@ -56,9 +78,19 @@ class ResolverChain( } } + /** + * 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 + val strategyAllowsNull = nullableStrategy is NullableStrategy.AlwaysNull || + nullableStrategy is NullableStrategy.NullOnCircularReference if (type.isMarkedNullable && strategyAllowsNull) { return null diff --git a/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt b/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt index 78221b07..5b064a84 100644 --- a/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt +++ b/src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt @@ -12,8 +12,11 @@ import kotlin.reflect.full.createType * * - **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.NullOnCircularReference, @@ -21,6 +24,15 @@ class NullableResolver( ) : 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) @@ -37,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 @@ -45,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 index 9a1759af..83e0c95f 100644 --- a/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt +++ b/src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt @@ -20,6 +20,49 @@ class CircularReferenceIntegrationTest { 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 { From b1c6b85ceaea7a5776bc1f2bbbbece4d9cfa19ca Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Fri, 8 May 2026 17:43:25 +0100 Subject: [PATCH 9/9] refactor: SomeConfig immutable --- .ignore | 2 +- docs/configuration/index.md | 43 ++++++----- src/main/kotlin/dev/appoutlet/some/Some.kt | 11 +-- .../dev/appoutlet/some/config/SomeConfig.kt | 53 ++++++------- .../some/config/SomeConfigBuilder.kt | 76 +++++++++++++++++++ .../SomeUnresolvableTypeException.kt | 2 +- .../some/resolver/ArrayResolverTest.kt | 4 +- .../some/resolver/ListResolverTest.kt | 4 +- .../some/resolver/MapResolverTest.kt | 4 +- .../some/resolver/NullableResolverTest.kt | 16 +--- .../some/resolver/SetResolverTest.kt | 4 +- .../some/resolver/StringResolverTest.kt | 4 +- 12 files changed, 141 insertions(+), 82 deletions(-) create mode 100644 src/main/kotlin/dev/appoutlet/some/config/SomeConfigBuilder.kt diff --git a/.ignore b/.ignore index 2c76037f..d7e9669e 100644 --- a/.ignore +++ b/.ignore @@ -1 +1 @@ -docs/reference \ No newline at end of file +docs/reference diff --git a/docs/configuration/index.md b/docs/configuration/index.md index e17ad3bf..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.NullOnCircularReference, - 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,15 +82,6 @@ val result: Person = baseSome { val stillNeverNull: Person = baseSome() ``` -### Copying configurations - -`SomeConfig.copy()` creates a new configuration with a fresh copy of the `factories` map. This prevents shared mutable factory registrations between config instances: - -```kotlin -val base = SomeConfig().apply { stringStrategy = StringStrategy.Uuid } -val custom = base.copy(seed = 999L) -``` - ## Default configuration | Property | Default | Description | Docs | @@ -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/src/main/kotlin/dev/appoutlet/some/Some.kt b/src/main/kotlin/dev/appoutlet/some/Some.kt index 8ab3ef73..e44508a8 100644 --- a/src/main/kotlin/dev/appoutlet/some/Some.kt +++ b/src/main/kotlin/dev/appoutlet/some/Some.kt @@ -1,6 +1,7 @@ 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 @@ -51,8 +52,8 @@ class Some( * @param config Configuration overrides for this call. * @return Generated value of type [T]. */ - inline operator fun invoke(noinline config: SomeConfig.() -> Unit = {}): T { - val aggregatedConfig = this.config.copy().apply(config) + 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() } } @@ -65,8 +66,8 @@ class Some( * @param config Configuration applied to the created generator. * @return A configured [Some] instance. */ -fun someSetup(config: SomeConfig.() -> Unit = {}): Some { - val someConfig = SomeConfig().apply(config) +fun someSetup(config: SomeConfigBuilder.() -> Unit = {}): Some { + val someConfig = SomeConfigBuilder().apply(config).build() val random = someConfig.buildRandom() return Some(someConfig.buildResolvers(random), random, someConfig) } @@ -99,7 +100,7 @@ inline fun some(): T { * @param config Configuration applied only to this generation call. * @return Generated value of type [T]. */ -inline fun some(noinline config: SomeConfig.() -> Unit = {}): 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/SomeConfig.kt b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt index abd688c2..476d01f0 100644 --- a/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt +++ b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt @@ -36,11 +36,14 @@ import kotlin.random.Random import kotlin.reflect.KClass /** - * Configuration for customizing the behavior of [some] fixture generation. + * 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]. @@ -49,40 +52,25 @@ import kotlin.reflect.KClass * @param factories Map of custom factory functions registered for specific types. */ data class SomeConfig( - var nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference, - 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 - } - /** - * Returns a copy of this configuration with an independent factories map. + * Creates a [SomeConfigBuilder] pre-populated with this configuration's values. * - * The strategy values are reused unless replacement values are provided. + * Useful for deriving new configurations from an existing one without mutation. * - * @param nullableStrategy Nullable strategy for the copied configuration. - * @param stringStrategy String strategy for the copied configuration. - * @param collectionStrategy Collection strategy for the copied configuration. - * @param seed Random seed for the copied configuration. - * @return A new [SomeConfig] containing the provided values and copied factory registrations. + * @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) } /** @@ -128,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/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/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)