diff --git a/core/src/main/kotlin/dev/appoutlet/some/config/FloatStrategy.kt b/core/src/main/kotlin/dev/appoutlet/some/config/FloatStrategy.kt new file mode 100644 index 00000000..baeca447 --- /dev/null +++ b/core/src/main/kotlin/dev/appoutlet/some/config/FloatStrategy.kt @@ -0,0 +1,22 @@ +package dev.appoutlet.some.config + +data class FloatStrategy( + val range: ClosedFloatingPointRange = 0.0f..1.0f +) : Strategy { + override val key = FloatStrategy::class + + constructor(fixed: Float) : this(fixed..fixed) + + init { + require(range.start <= range.endInclusive) { + "range.start must be less than or equal to range.endInclusive" + } + } + + companion object { + /** + * The default float strategy. + */ + val default = FloatStrategy() + } +} diff --git a/core/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt b/core/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt index 631dd6f7..152670e0 100644 --- a/core/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt +++ b/core/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt @@ -116,7 +116,7 @@ data class SomeConfig( IntResolver(random), LongResolver(random), DoubleResolver(random), - FloatResolver(random), + FloatResolver(strategyProvider, random), BooleanResolver(random), CharResolver(random), ByteResolver(random), diff --git a/core/src/main/kotlin/dev/appoutlet/some/config/SomeConfigBuilder.kt b/core/src/main/kotlin/dev/appoutlet/some/config/SomeConfigBuilder.kt index 0f284038..5e4edb99 100644 --- a/core/src/main/kotlin/dev/appoutlet/some/config/SomeConfigBuilder.kt +++ b/core/src/main/kotlin/dev/appoutlet/some/config/SomeConfigBuilder.kt @@ -21,6 +21,7 @@ import kotlin.reflect.full.instanceParameter * strategy(NullableStrategy.NeverNull) * strategy(StringStrategy.Uuid) * strategy(CollectionStrategy(5..10)) + * strategy(FloatStrategy(0.0f..10.0f)) * } * ``` * diff --git a/core/src/main/kotlin/dev/appoutlet/some/config/Strategy.kt b/core/src/main/kotlin/dev/appoutlet/some/config/Strategy.kt index 629cd044..57365a72 100644 --- a/core/src/main/kotlin/dev/appoutlet/some/config/Strategy.kt +++ b/core/src/main/kotlin/dev/appoutlet/some/config/Strategy.kt @@ -6,7 +6,7 @@ import kotlin.reflect.KClass * Marker interface for strategy objects that configure fixture generation behavior. * * All built-in strategies ([NullableStrategy], [StringStrategy], [CollectionStrategy], - * and [DefaultValueStrategy]) implement this interface. Custom strategies can also be + * [FloatStrategy], and [DefaultValueStrategy]) implement this interface. Custom strategies can also be * created by implementing [Strategy] and registering them via [SomeConfigBuilder.strategy]. * * The [key] property determines the registration bucket for the strategy. For sealed interface diff --git a/core/src/main/kotlin/dev/appoutlet/some/resolver/FloatResolver.kt b/core/src/main/kotlin/dev/appoutlet/some/resolver/FloatResolver.kt index 6da28d26..86071862 100644 --- a/core/src/main/kotlin/dev/appoutlet/some/resolver/FloatResolver.kt +++ b/core/src/main/kotlin/dev/appoutlet/some/resolver/FloatResolver.kt @@ -1,15 +1,31 @@ package dev.appoutlet.some.resolver +import dev.appoutlet.some.config.FloatStrategy 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 -class FloatResolver(val random: Random) : Resolver { +class FloatResolver( + strategyProvider: StrategyProvider, + private val random: Random +) : Resolver { + private val floatStrategy = strategyProvider.get() ?: FloatStrategy.default + override fun canResolve(type: KType): Boolean = type == typeOf() override fun resolve(type: KType, chain: ResolverChain): Any { - return random.nextFloat() + val range = floatStrategy.range + return if (range.start == range.endInclusive) { + range.start + } else { + random.nextDouble( + from = range.start.toDouble(), + until = range.endInclusive.toDouble(), + ).toFloat() + } } } diff --git a/core/src/test/kotlin/dev/appoutlet/some/config/FloatStrategyTest.kt b/core/src/test/kotlin/dev/appoutlet/some/config/FloatStrategyTest.kt new file mode 100644 index 00000000..48f11e9c --- /dev/null +++ b/core/src/test/kotlin/dev/appoutlet/some/config/FloatStrategyTest.kt @@ -0,0 +1,67 @@ +package dev.appoutlet.some.config + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class FloatStrategyTest { + @Test + fun `FloatStrategy has default range 0_0 to 1_0`() { + val strategy = FloatStrategy() + assertEquals(0.0f, strategy.range.start) + assertEquals(1.0f, strategy.range.endInclusive) + } + + @Test + fun `FloatStrategy default companion is no-arg instance`() { + val strategy = FloatStrategy.default + assertEquals(0.0f, strategy.range.start) + assertEquals(1.0f, strategy.range.endInclusive) + } + + @Test + fun `FloatStrategy accepts valid range`() { + val strategy = FloatStrategy(1.0f..5.0f) + assertEquals(1.0f, strategy.range.start) + assertEquals(5.0f, strategy.range.endInclusive) + } + + @Test + fun `FloatStrategy accepts zero-width range`() { + val strategy = FloatStrategy(3.0f..3.0f) + assertEquals(3.0f, strategy.range.start) + assertEquals(3.0f, strategy.range.endInclusive) + } + + @Test + fun `FloatStrategy accepts negative range`() { + val strategy = FloatStrategy(-5.0f..-1.0f) + assertEquals(-5.0f, strategy.range.start) + assertEquals(-1.0f, strategy.range.endInclusive) + } + + @Test + fun `FloatStrategy rejects inverted range`() { + val exception = assertFailsWith { + FloatStrategy(5.0f..2.0f) + } + assertEquals( + "range.start must be less than or equal to range.endInclusive", + exception.message + ) + } + + @Test + fun `FloatStrategy fixed constructor is equivalent to zero-width range`() { + val fixed = FloatStrategy(3.0f) + val range = FloatStrategy(3.0f..3.0f) + assertEquals(range.range.start, fixed.range.start) + assertEquals(range.range.endInclusive, fixed.range.endInclusive) + } + + @Test + fun `FloatStrategy key is its own class`() { + assertSame(FloatStrategy::class, FloatStrategy().key) + } +} diff --git a/core/src/test/kotlin/dev/appoutlet/some/resolver/FloatResolverTest.kt b/core/src/test/kotlin/dev/appoutlet/some/resolver/FloatResolverTest.kt index 2f9ef15a..ad0a0de6 100644 --- a/core/src/test/kotlin/dev/appoutlet/some/resolver/FloatResolverTest.kt +++ b/core/src/test/kotlin/dev/appoutlet/some/resolver/FloatResolverTest.kt @@ -1,9 +1,15 @@ package dev.appoutlet.some.resolver +import dev.appoutlet.some.config.DefaultStrategyProvider +import dev.appoutlet.some.config.FloatStrategy +import dev.appoutlet.some.config.NullableStrategy +import dev.appoutlet.some.config.buildSomeConfig +import dev.appoutlet.some.core.ResolverChain import dev.appoutlet.some.test.defaultTestChain import kotlin.random.Random import kotlin.reflect.typeOf import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue @@ -11,15 +17,15 @@ import kotlin.test.assertTrue class FloatResolverTest { @Test fun `FloatResolver generates float values`() { - val resolver = FloatResolver(Random.Default) + val resolver = FloatResolver(DefaultStrategyProvider(), Random.Default) val result = resolver.resolve(typeOf(), defaultTestChain) assertIs(result) } @Test - fun `FloatResolver generates values between 0 and 1`() { - val resolver = FloatResolver(Random.Default) + fun `FloatResolver generates values between 0 and 1 by default`() { + val resolver = FloatResolver(DefaultStrategyProvider(), Random.Default) repeat(100) { val result = resolver.resolve(typeOf(), defaultTestChain) as Float @@ -27,17 +33,70 @@ class FloatResolverTest { } } + @Test + fun `FloatResolver honors a custom range`() { + val resolver = FloatResolver( + DefaultStrategyProvider(mapOf(FloatStrategy::class to FloatStrategy(0.0f..10.0f))), + Random.Default + ) + + repeat(100) { + val result = resolver.resolve(typeOf(), defaultTestChain) as Float + assertTrue(result in 0.0f..<10.0f, "Expected value between 0.0 and 10.0, got $result") + } + } + + @Test + fun `FloatResolver honors a negative range`() { + val resolver = FloatResolver( + DefaultStrategyProvider(mapOf(FloatStrategy::class to FloatStrategy(-5.0f..-1.0f))), + Random.Default + ) + + repeat(100) { + val result = resolver.resolve(typeOf(), defaultTestChain) as Float + assertTrue(result in -5.0f..<-1.0f, "Expected value between -5.0 and -1.0, got $result") + } + } + + @Test + fun `FloatResolver always returns fixed value for pinned strategy`() { + val resolver = FloatResolver( + DefaultStrategyProvider(mapOf(FloatStrategy::class to FloatStrategy(5.0f))), + Random.Default + ) + + repeat(50) { + val result = resolver.resolve(typeOf(), defaultTestChain) as Float + assertEquals(5.0f, result) + } + } + @Test fun `FloatResolver canResolve detects Float type`() { - val resolver = FloatResolver(Random.Default) + val resolver = FloatResolver(DefaultStrategyProvider(), Random.Default) assertTrue(resolver.canResolve(typeOf())) } @Test fun `FloatResolver rejects non-Float types`() { - val resolver = FloatResolver(Random.Default) + val resolver = FloatResolver(DefaultStrategyProvider(), Random.Default) assertFalse(resolver.canResolve(typeOf())) assertFalse(resolver.canResolve(typeOf())) assertFalse(resolver.canResolve(typeOf())) } + + @Test + fun `FloatStrategy integrates with SomeConfig`() { + val config = buildSomeConfig { + strategy(FloatStrategy(0.0f..10.0f)) + } + val resolvers = config.buildResolvers() + val chain = ResolverChain(resolvers, config[NullableStrategy::class]) + + repeat(50) { + val result = chain.resolve(typeOf()) as Float + assertTrue(result in 0.0f..<10.0f, "Expected value between 0.0 and 10.0, got $result") + } + } } diff --git a/docs/configuration/float-strategy.md b/docs/configuration/float-strategy.md new file mode 100644 index 00000000..d17070cd --- /dev/null +++ b/docs/configuration/float-strategy.md @@ -0,0 +1,63 @@ +# Float Strategy + +Controls the range of `Float` values generated during fixture generation. + +## Configuration + +The `FloatStrategy` takes a single `range` parameter — a `ClosedFloatingPointRange` specifying the inclusive minimum and inclusive maximum of generated `Float` values. The end of the range is treated as exclusive during generation, matching `random.nextDouble(from, until)` semantics. + +```kotlin +// Default: 0.0 to 1.0 (exclusive end) +some { strategy(FloatStrategy()) } + +// Positive range +some { strategy(FloatStrategy(0.0f..10.0f)) } + +// Negative range +some { strategy(FloatStrategy(-5.0f..-1.0f)) } + +// Range that spans zero +some { strategy(FloatStrategy(-1.0f..1.0f)) } +``` + +## Pinning a fixed value + +A secondary constructor `FloatStrategy(fixed: Float)` is provided as a convenience for pinning generation to a single value. It is equivalent to `FloatStrategy(fixed..fixed)`. + +```kotlin +// Pin to a single value +some { strategy(FloatStrategy(5.0f)) } + +// Equivalent zero-width range +some { strategy(FloatStrategy(5.0f..5.0f)) } +``` + +Zero-width ranges (`start == endInclusive`) are always honored — the resolver returns the fixed value directly without invoking the random source. + +## Default + +| Property | Default value | +|----------|---------------| +| `range` | `0.0f..1.0f` | + +The default range preserves the behavior of `random.nextFloat()` (`[0.0, 1.0)`), so existing users see no change. + +## Affected types + +The float strategy applies to the `Float` primitive type: + +- `Float` + +## Validation + +The `FloatStrategy` constructor enforces one constraint on the `range` parameter via a `require()` check. It throws `IllegalArgumentException` on failure. + +### Start must be less than or equal to end + +`range.start` must be `<=` `range.endInclusive`. Inverted ranges are rejected; equal bounds (zero-width ranges) are allowed. + +```kotlin +FloatStrategy(0.0f..1.0f) // OK +FloatStrategy(5.0f..2.0f) // IllegalArgumentException: range.start must be less than or equal to range.endInclusive +FloatStrategy(3.0f..3.0f) // OK — zero-width range, always returns 3.0f +``` diff --git a/docs/configuration/index.md b/docs/configuration/index.md index b0d752cf..5673078a 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -71,6 +71,7 @@ val stillNeverNull: Person = baseSome() | `NullableStrategy` | `NullableStrategy.NullOnCircularReference` | Emits `null` for nullable circular references | [NullableStrategy](nullable-strategy.md) | | `StringStrategy` | `StringStrategy.Random()` | Random lowercase alphabetic strings | [StringStrategy](string-strategy.md) | | `CollectionStrategy` | `CollectionStrategy()` | Collections with 1 to 5 elements | [CollectionStrategy](collection-strategy.md) | +| `FloatStrategy` | `FloatStrategy()` | Floats in the range `0.0f..1.0f` | [FloatStrategy](float-strategy.md) | | `DefaultValueStrategy` | `DefaultValueStrategy.UseDefault` | Uses Kotlin defaults for optional parameters | [DefaultValueStrategy](default-value-strategy.md) | | `seed` | `null` | Uses non-deterministic `Random.Default` | — | diff --git a/docs/supported-types.md b/docs/supported-types.md index a7b37fd4..d865b0a1 100644 --- a/docs/supported-types.md +++ b/docs/supported-types.md @@ -16,7 +16,7 @@ For types not listed here, register a [custom factory](custom-factories.md) or s | `Int` | `some()` | | | `Long` | `some()` | | | `Double` | `some()` | | -| `Float` | `some()` | | +| `Float` | `some()` | See [FloatStrategy](configuration/float-strategy.md) | | `Boolean` | `some()` | | | `Char` | `some()` | | | `Byte` | `some()` | | diff --git a/zensical.toml b/zensical.toml index 74433c6e..90ddf090 100644 --- a/zensical.toml +++ b/zensical.toml @@ -13,7 +13,8 @@ nav = [ "configuration/index.md" , "configuration/nullable-strategy.md", "configuration/string-strategy.md", - "configuration/collection-strategy.md" + "configuration/collection-strategy.md", + "configuration/float-strategy.md" ] }, "supported-types.md", "custom-factories.md",