Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ android {
}

defaultConfig {
minSdk = 24
minSdk = 26
testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ data class SomeConfig(
JavaUuidResolver(),
JavaInstantResolver(random),
JavaDurationResolver(random),
JavaZonedDateTimeResolver(random),
JavaZonedDateTimeResolver(strategyProvider, random),
OptionalResolver(strategyProvider, random),
BigDecimalResolver(random),
BigIntegerResolver(random),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package dev.appoutlet.some.config

import java.time.Instant
import java.time.ZoneId

/**
* Strategy for controlling how [java.time.ZonedDateTime] values are generated.
*
* The strategy selects the time window for generated instants and, for [Range],
* an optional fixed [ZoneId]. All non-[Range] variants use a random available JVM [ZoneId].
*
* ## Available Strategies
*
* - [Default] – (Default) Generates instants across the full [Instant] range with a random [ZoneId].
* - [NearPast] – Generates instants from 10 years before now until now.
* - [NearFuture] – Generates instants from now until 10 years after now.
* - [DistantPast] – Generates instants from [Instant.MIN] until now.
* - [DistantFuture] – Generates instants from now until [Instant.MAX].
* - [Range] – Generates instants within user-defined bounds, optionally pinned to a specific [ZoneId].
*
* ## Example Usage
*
* ```kotlin
* // Default full-range behavior
* some { strategy(ZonedDateTimeStrategy.Default) }
*
* // Values from the last 10 years
* some { strategy(ZonedDateTimeStrategy.NearPast) }
*
* // Custom range with a random ZoneId
* some { strategy(ZonedDateTimeStrategy.Range(min, max)) }
*
* // Custom range pinned to a specific ZoneId
* some { strategy(ZonedDateTimeStrategy.Range(min, max, ZoneId.of("America/Sao_Paulo"))) }
* ```
*/
sealed interface ZonedDateTimeStrategy : Strategy {
override val key get() = ZonedDateTimeStrategy::class

/**
* Generates instants across the full [Instant] range with a random [ZoneId].
*
* This is the default strategy and matches the historical behavior before
* [ZonedDateTimeStrategy] was introduced.
*/
data object Default : ZonedDateTimeStrategy

/**
* Generates instants from 10 years before now until now.
*/
data object NearPast : ZonedDateTimeStrategy

/**
* Generates instants from now until 10 years after now.
*/
data object NearFuture : ZonedDateTimeStrategy

/**
* Generates instants from [Instant.MIN] until now.
*/
data object DistantPast : ZonedDateTimeStrategy

/**
* Generates instants from now until [Instant.MAX].
*/
data object DistantFuture : ZonedDateTimeStrategy

/**
* Generates instants within the inclusive range [[min], [max]], optionally pinned to [zoneId].
*
* When [zoneId] is `null`, a random available JVM [ZoneId] is used.
*
* @property min The inclusive lower bound of generated instants.
* @property max The inclusive upper bound of generated instants.
* @property zoneId The fixed zone to use, or `null` for a random zone.
*
* @throws IllegalArgumentException if [min] is greater than [max].
*/
data class Range(
val min: Instant,
val max: Instant,
val zoneId: ZoneId? = null,
) : ZonedDateTimeStrategy {
init {
require(min <= max) {
"ZonedDateTimeStrategy.Range requires min <= max, but got min=$min and max=$max"
}
}
}

companion object {
/**
* The default [ZonedDateTimeStrategy].
*/
val default: ZonedDateTimeStrategy get() = Default
}
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,70 @@
package dev.appoutlet.some.resolver

import dev.appoutlet.some.config.ZonedDateTimeStrategy
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 java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import kotlin.random.Random
import kotlin.reflect.KType
import kotlin.reflect.typeOf

private const val TEN_YEARS = 10L

/**
* Resolves [ZonedDateTime] instances.
*
* Generated values fall within the range 1970-01-01 to 2100-12-31.
* Each instance uses a randomly selected [ZoneId] from the available set on the JVM.
* Generated values are controlled by the active [ZonedDateTimeStrategy]. By default, values are
* spread across the full [Instant] range and use a randomly selected [ZoneId] from the available
* set on the JVM.
*
* @param strategyProvider Provider of configured strategies.
* @param random The random source used for generation.
*/
class JavaZonedDateTimeResolver(private val random: Random) : Resolver {
class JavaZonedDateTimeResolver(
private val strategyProvider: StrategyProvider,
private val random: Random,
) : Resolver {
private val zoneIds by lazy { ZoneId.getAvailableZoneIds().toList() }

override fun canResolve(type: KType): Boolean {
return type == typeOf<ZonedDateTime>()
}

override fun resolve(type: KType, chain: ResolverChain): Any {
val epochSecond = random.nextLong(Instant.MIN.epochSecond, Instant.MAX.epochSecond)
val zoneId = zoneIds[random.nextInt(zoneIds.size)]
return ZonedDateTime.ofInstant(Instant.ofEpochSecond(epochSecond), ZoneId.of(zoneId))
val strategy = strategyProvider.get<ZonedDateTimeStrategy>() ?: ZonedDateTimeStrategy.default
val now = Instant.now()

val (minInstant, maxInstant) = when (strategy) {
ZonedDateTimeStrategy.Default -> Instant.MIN to Instant.MAX
ZonedDateTimeStrategy.NearPast -> now.minusYears(TEN_YEARS) to now
ZonedDateTimeStrategy.NearFuture -> now to now.plusYears(TEN_YEARS)
ZonedDateTimeStrategy.DistantPast -> Instant.MIN to now
ZonedDateTimeStrategy.DistantFuture -> now to Instant.MAX
is ZonedDateTimeStrategy.Range -> strategy.min to strategy.max
}

val epochSecond = randomEpochSecond(minInstant, maxInstant)

val zoneId = when (strategy) {
is ZonedDateTimeStrategy.Range -> strategy.zoneId ?: ZoneId.of(zoneIds.random())
else -> ZoneId.of(zoneIds.random())
}

return ZonedDateTime.ofInstant(Instant.ofEpochSecond(epochSecond), zoneId)
}

private fun randomEpochSecond(min: Instant, max: Instant): Long {
return random.nextLong(min.epochSecond, max.epochSecond)
}

private fun Instant.minusYears(years: Long): Instant =
this.atZone(ZoneOffset.UTC).minusYears(years).toInstant()

private fun Instant.plusYears(years: Long): Instant =
this.atZone(ZoneOffset.UTC).plusYears(years).toInstant()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package dev.appoutlet.some.config

import java.time.Instant
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertSame

class ZonedDateTimeStrategyTest {
@Test
fun `default strategy is Default`() {
assertSame(ZonedDateTimeStrategy.Default, ZonedDateTimeStrategy.default)
}

@Test
fun `Range accepts valid min and max`() {
val min = Instant.parse("2020-01-01T00:00:00Z")
val max = Instant.parse("2025-01-01T00:00:00Z")

val strategy = ZonedDateTimeStrategy.Range(min, max)

assertEquals(min, strategy.min)
assertEquals(max, strategy.max)
assertEquals(null, strategy.zoneId)
}

@Test
fun `Range accepts equal min and max`() {
val instant = Instant.parse("2020-01-01T00:00:00Z")

val strategy = ZonedDateTimeStrategy.Range(instant, instant)

assertEquals(instant, strategy.min)
assertEquals(instant, strategy.max)
}

@Test
fun `Range accepts custom zoneId`() {
val min = Instant.parse("2020-01-01T00:00:00Z")
val max = Instant.parse("2025-01-01T00:00:00Z")
val zoneId = ZoneId.of("America/Sao_Paulo")

val strategy = ZonedDateTimeStrategy.Range(min, max, zoneId)

assertEquals(zoneId, strategy.zoneId)
}

@Test
fun `Range rejects inverted min and max`() {
val min = Instant.parse("2025-01-01T00:00:00Z")
val max = Instant.parse("2020-01-01T00:00:00Z")

val error = assertFailsWith<IllegalArgumentException> {
ZonedDateTimeStrategy.Range(min, max)
}

assertEquals(
"ZonedDateTimeStrategy.Range requires min <= max, but got min=$min and max=$max",
error.message,
)
}

@Test
fun `key is ZonedDateTimeStrategy class`() {
assertSame(ZonedDateTimeStrategy::class, ZonedDateTimeStrategy.Default.key)
assertSame(ZonedDateTimeStrategy::class, ZonedDateTimeStrategy.NearPast.key)
assertSame(
ZonedDateTimeStrategy::class,
ZonedDateTimeStrategy.Range(Instant.MIN, Instant.MAX).key,
)
}
}
Loading