package pw.binom.llmproxy import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds class BackoffTest { private val t0 = Instant.fromEpochSeconds(1_700_000_000) @Test fun intervalsDoubleUntilCap() = runTest { val g = BackoffGuard(cap = 30.seconds) val seq = (1..7).map { g.recordFailure(t0) } assertEquals( listOf(1.seconds, 2.seconds, 4.seconds, 8.seconds, 16.seconds, 30.seconds, 30.seconds), seq, ) } @Test fun successResetsCounter() = runTest { val g = BackoffGuard(cap = 30.seconds) g.recordFailure(t0) g.recordFailure(t0) g.recordSuccess() assertEquals(1.seconds, g.recordFailure(t0)) assertFalse(g.isCoolingAt(t0 + 1.days)) } @Test fun coolingWindow() = runTest { val g = BackoffGuard(cap = 30.seconds) g.recordFailure(t0) // отдых 1s assertTrue(g.isCoolingAt(t0 + 500.milliseconds)) assertFalse(g.isCoolingAt(t0 + 1.seconds)) assertEquals(500.milliseconds, g.remainingAt(t0 + 500.milliseconds)) } @Test fun iso8601CapParsing() { assertEquals(30.minutes, Duration.parse("PT30M")) assertEquals(1.days, Duration.parse("P1D")) assertEquals(90.seconds, Duration.parse("PT1M30S")) } @Test fun configDurationParsingWithYearsAndMonths() { // kotlin.time не принимает Y/M — конфиг-парсер расширяет: 1Y≈365d, 1M≈30d assertEquals(30.days, parseIsoDuration("P1M")) assertEquals(365.days, parseIsoDuration("P1Y")) assertEquals(455.days, parseIsoDuration("P1Y3M")) assertEquals(7.days, parseIsoDuration("P1W")) assertEquals(2.days + 3.hours + 15.minutes, parseIsoDuration("P2DT3H15M")) // базовый ISO-8601 без Y/M — как kotlin.time assertEquals(30.minutes, parseIsoDuration("PT30M")) } @Test fun configDurationParsingRejectsGarbage() { assertFails { parseIsoDuration("PT30") } assertFails { parseIsoDuration("1d") } assertFails { parseIsoDuration("P") } assertFails { parseIsoDuration("") } } @Test fun providerScopeSharedBetweenItsModels() = runTest { val p = ProviderConf(id = "p", url = "https://p", backoff = 5.minutes) val a = UpstreamConf(id = "a", provider = "p", model = "m1") val b = UpstreamConf(id = "b", provider = "p", model = "m2") val reg = BackoffRegistry(mapOf("p" to p), mapOf("a" to a, "b" to b)) assertEquals(1.seconds, reg.recordFailure(a)) // общая скоуп-переменная провайдера: сбой на a охладил и b assertTrue(reg.isCooling(a)) assertTrue(reg.isCooling(b)) } @Test fun upstreamBackoffOverridesProvider() = runTest { val p = ProviderConf(id = "p", url = "https://p", backoff = 5.minutes) val a = UpstreamConf(id = "a", provider = "p", model = "m1") val b = UpstreamConf(id = "b", provider = "p", model = "m2", backoff = 30.seconds) val reg = BackoffRegistry(mapOf("p" to p), mapOf("a" to a, "b" to b)) // своё backoff у b: cap 30s (не 5m провайдера): 1s, 2s, 4s, 8s, 16s, 30s (потолок) val seq = (1..6).map { reg.recordFailure(b) } assertEquals(listOf(1.seconds, 2.seconds, 4.seconds, 8.seconds, 16.seconds, 30.seconds), seq) // скоупы независимы: сбои b не охлаждают a (провайдерский скоуп) assertFalse(reg.isCooling(a)) assertTrue(reg.isCooling(b)) } @Test fun providerFailureDoesNotCoolUpstreamWithOwnBackoff() = runTest { val p = ProviderConf(id = "p", url = "https://p", backoff = 5.minutes) val a = UpstreamConf(id = "a", provider = "p", model = "m1") val b = UpstreamConf(id = "b", provider = "p", model = "m2", backoff = 30.minutes) val reg = BackoffRegistry(mapOf("p" to p), mapOf("a" to a, "b" to b)) // провайдерский скоуп заведён сбоем на a: reg.recordFailure(a) assertTrue(reg.isCooling(a)) // b живёт своим (provider-scope на него не действует): assertFalse(reg.isCooling(b)) } @Test fun noBackoffConfiguredMeansNoCooling() = runTest { val p = ProviderConf(id = "p", url = "https://p") val a = UpstreamConf(id = "a", provider = "p", model = "m1") val reg = BackoffRegistry(mapOf("p" to p), mapOf("a" to a)) assertNull(reg.recordFailure(a)) assertFalse(reg.isCooling(a)) assertEquals(0L, reg.remainingSeconds(a)) } }