diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/cards/ReviewCardTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/cards/ReviewCardTest.kt index fb1efee9..0e1607ea 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/cards/ReviewCardTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/cards/ReviewCardTest.kt @@ -11,10 +11,17 @@ import androidx.compose.ui.test.performSemanticsAction import androidx.test.core.app.ApplicationProvider import eu.darken.capod.R import eu.darken.capod.common.compose.PreviewWrapper -import org.junit.Assert.assertTrue +import io.kotest.matchers.shouldBe import org.junit.Test import testhelpers.compose.BaseComposeRobolectricTest +/** + * The card only disappears once the next state emission arrives, so its two tap targets need a + * latch: a dismiss after a review would overwrite the completed-review bookkeeping with a snooze, + * a review after a dismiss would re-open what the user just closed. The latch is asymmetric — + * repeated review taps stay allowed, because a Play request can fail without persisting anything, + * which leaves the card on screen and in need of a retry. + */ class ReviewCardTest : BaseComposeRobolectricTest() { private val context: Context @@ -27,6 +34,21 @@ class ReviewCardTest : BaseComposeRobolectricTest() { // The card's title reuses the review label, so the action is matched by its click semantics. private val reviewButton get() = hasText(reviewLabel) and hasClickAction() + private var reviews = 0 + private var dismisses = 0 + + private fun setContent(withActivity: Boolean = true) { + composeRule.setContent { + PreviewWrapper { + ReviewCard( + // Null callback = no Activity to launch Play's review flow with. + onReview = if (withActivity) ({ reviews++ }) else null, + onDismiss = { dismisses++ }, + ) + } + } + } + @Test fun `renders the body and both actions`() { composeRule.setContent { @@ -40,29 +62,6 @@ class ReviewCardTest : BaseComposeRobolectricTest() { composeRule.onNode(reviewButton).assertIsEnabled() } - @Test - fun `both actions invoke their callback`() { - var reviewed = false - var dismissed = false - - composeRule.setContent { - PreviewWrapper { - ReviewCard( - onReview = { reviewed = true }, - onDismiss = { dismissed = true }, - ) - } - } - - composeRule.onNodeWithText(dismissLabel).performSemanticsAction(SemanticsActions.OnClick) - composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick) - - composeRule.runOnIdle { - assertTrue(dismissed) - assertTrue(reviewed) - } - } - @Test fun `the review action is disabled without a hosting activity`() { composeRule.setContent { @@ -76,4 +75,69 @@ class ReviewCardTest : BaseComposeRobolectricTest() { // Dismissing has to stay possible, it doesn't need an Activity. composeRule.onNodeWithText(dismissLabel).assertIsEnabled() } + + @Test + fun `a dismissed card ignores a later review tap`() { + setContent() + + composeRule.onNodeWithText(dismissLabel).performSemanticsAction(SemanticsActions.OnClick) + composeRule.runOnIdle { dismisses shouldBe 1 } + + composeRule.onNode(reviewButton).assertIsNotEnabled() + composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick) + + composeRule.runOnIdle { + reviews shouldBe 0 + dismisses shouldBe 1 + } + } + + @Test + fun `a reviewed card ignores a later dismiss tap`() { + setContent() + + composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick) + composeRule.runOnIdle { reviews shouldBe 1 } + + composeRule.onNodeWithText(dismissLabel).assertIsNotEnabled() + composeRule.onNodeWithText(dismissLabel).performSemanticsAction(SemanticsActions.OnClick) + + composeRule.runOnIdle { + reviews shouldBe 1 + dismisses shouldBe 0 + } + } + + @Test + fun `repeated review taps are not absorbed by the card`() { + setContent() + + composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick) + composeRule.runOnIdle { reviews shouldBe 1 } + + // A failed Play request persists nothing and leaves the card up, so the retry has to work. + // Duplicates are the tool's problem, it holds a single-flight lock for exactly this. + composeRule.onNode(reviewButton).assertIsEnabled() + composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick) + + composeRule.runOnIdle { + reviews shouldBe 2 + dismisses shouldBe 0 + } + } + + @Test + fun `a review tap without an activity consumes neither latch`() { + setContent(withActivity = false) + + composeRule.onNode(reviewButton).assertIsNotEnabled() + composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick) + composeRule.runOnIdle { reviews shouldBe 0 } + + // Nothing was handed to the caller, so the card must still be dismissable + composeRule.onNodeWithText(dismissLabel).assertIsEnabled() + composeRule.onNodeWithText(dismissLabel).performSemanticsAction(SemanticsActions.OnClick) + + composeRule.runOnIdle { dismisses shouldBe 1 } + } } diff --git a/app/src/testGplay/java/eu/darken/capod/common/review/GplayReviewToolTest.kt b/app/src/testGplay/java/eu/darken/capod/common/review/GplayReviewToolTest.kt index 82d589a6..ef74af64 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/review/GplayReviewToolTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/review/GplayReviewToolTest.kt @@ -17,13 +17,18 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerializationException import org.junit.jupiter.api.Test @@ -39,20 +44,33 @@ class GplayReviewToolTest : BaseTest() { private val upgradeRepo = mockk() private lateinit var lastDismissedMock: DataStoreValue private lateinit var reviewedAtMock: DataStoreValue + private lateinit var lastDismissedFlow: MutableStateFlow // Relaxed so the `.value(new)` writes (which go through `update`) succeed and can be verified. private fun rwSetting(initial: T): DataStoreValue = mockk>(relaxed = true).apply { every { flow } returns flowOf(initial) } + // Hot variant: a value written after the tool started collecting has to reach the pipeline, + // which a one-shot `flowOf` can't do. + private fun hotSetting(source: Flow): DataStoreValue = mockk>(relaxed = true).apply { + every { flow } returns source + } + // The tool's own scope has to run on the test scheduler, otherwise the probe backoff and the // state throttle would burn real time. private fun TestScope.tool( upgradedAt: Instant? = Instant.now().minus(Duration.ofDays(30)), lastDismissed: Instant? = null, reviewedAt: Instant? = null, + minDuration: Duration? = null, + hotSettings: Boolean = false, ): GplayReviewTool { - lastDismissedMock = rwSetting(lastDismissed) + lastDismissedFlow = MutableStateFlow(lastDismissed) + lastDismissedMock = when { + hotSettings -> hotSetting(lastDismissedFlow) + else -> rwSetting(lastDismissed) + } reviewedAtMock = rwSetting(reviewedAt) every { settings.lastDismissed } returns lastDismissedMock every { settings.reviewedAt } returns reviewedAtMock @@ -66,7 +84,13 @@ class GplayReviewToolTest : BaseTest() { settings = settings, manager = manager, upgradeRepo = upgradeRepo, - ).apply { probeRetryDelay = Duration.ofSeconds(1) } + ).apply { + probeRetryDelay = PROBE_RETRY_DELAY + probeFailureCooldown = PROBE_FAILURE_COOLDOWN + requestTimeout = REQUEST_TIMEOUT + launchTimeout = LAUNCH_TIMEOUT + minDuration?.let { reviewMinDuration = it } + } } private fun reviewInfo(canShow: Boolean = true): ReviewInfo { @@ -86,9 +110,30 @@ class GplayReviewToolTest : BaseTest() { private fun launchOk(): Task = Tasks.forResult(null) + // A Play call that never comes back: the ktx wrapper suspends on the listeners it registers + // with the Task, and a relaxed mock never invokes them. + private fun hangingTask(): Task = mockk>(relaxed = true).apply { + every { isComplete } returns false + } + // The `onStart` seed is not a computed state, every assertion has to await the first real one. private suspend fun GplayReviewTool.computedState() = state.drop(1).first() + // Live view of everything the tool emitted so far, for assertions that have to move the clock + // between emissions instead of awaiting a single one. + private fun TestScope.collectStates(tool: GplayReviewTool): List { + val states = mutableListOf() + backgroundScope.launch { tool.state.collect { states += it } } + return states + } + + // The tool's flows all run on the background scope, and `advanceUntilIdle` only advances while + // there is foreground work, so every wait has to name the span it is waiting for. + private fun TestScope.advanceBy(duration: Duration) { + advanceTimeBy(duration.toMillis()) + runCurrent() + } + @Test fun `an eligible user is asked for a review`() = runTest2 { every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) @@ -117,6 +162,18 @@ class GplayReviewToolTest : BaseTest() { verify(exactly = 0) { manager.requestReviewFlow() } } + @Test fun `a user who already reviewed is never asked or probed again`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) + + tool(reviewedAt = Instant.now().minus(Duration.ofDays(200))).computedState().apply { + shouldAskForReview shouldBe false + // The card is gone for good, but the fact stays readable for anything else that asks. + hasReviewed shouldBe true + } + + verify(exactly = 0) { manager.requestReviewFlow() } + } + @Test fun `reviewNow launches with a freshly requested ReviewInfo`() = runTest2 { // ReviewInfo is short lived, the token used for the launch must not be the one the // availability probe obtained (potentially hours) earlier. @@ -173,6 +230,33 @@ class GplayReviewToolTest : BaseTest() { coVerify(exactly = 0) { reviewedAtMock.update(any()) } } + @Test fun `a launch the user dismissed instantly counts as a snooze`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) + every { manager.launchReviewFlow(any(), any()) } returns launchOk() + val tool = tool() + + tool.reviewNow(activity()) + + // Play returns immediately when it decides not to show anything, that is not a review. + coVerify(exactly = 1) { lastDismissedMock.update(any()) } + coVerify(exactly = 0) { reviewedAtMock.update(any()) } + } + + @Test fun `a launch the user stayed in counts as a completed review`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) + // Real sleep on purpose: the heuristic measures wall clock, virtual time cannot reach it. + every { manager.launchReviewFlow(any(), any()) } answers { + Thread.sleep(150) + launchOk() + } + val tool = tool(minDuration = Duration.ofMillis(50)) + + tool.reviewNow(activity()) + + coVerify(exactly = 1) { reviewedAtMock.update(any()) } + coVerify(exactly = 0) { lastDismissedMock.update(any()) } + } + @Test fun `a dead activity aborts the launch without persisting`() = runTest2 { every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) every { manager.launchReviewFlow(any(), any()) } returns launchOk() @@ -203,8 +287,10 @@ class GplayReviewToolTest : BaseTest() { val tool = tool() val activity = activity() + // runCurrent, not advanceUntilIdle: the first call has to be parked on the request when the + // second tap arrives, an unbounded time advance would trip the request timeout first. val first = launch { tool.reviewNow(activity) } - advanceUntilIdle() + runCurrent() tool.reviewNow(activity) @@ -236,6 +322,15 @@ class GplayReviewToolTest : BaseTest() { verify(exactly = 3) { manager.requestReviewFlow() } } + @Test fun `an isNoOp probe answer is accepted instead of retried`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo(canShow = false)) + + tool().computedState().shouldAskForReview shouldBe false + + // isNoOp is an answer, not a failure: burning the retry budget on it would just cost quota. + verify(exactly = 1) { manager.requestReviewFlow() } + } + @Test fun `corrupt review settings fall back to the default state`() = runTest2 { every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) lastDismissedMock = rwSetting(null) @@ -286,4 +381,292 @@ class GplayReviewToolTest : BaseTest() { // The general failure path would have burned all 3 attempts and settled on "unavailable". verify(exactly = 1) { manager.requestReviewFlow() } } + + @Test fun `a probe that times out abandons the whole round`() = runTest2 { + var hanging = true + every { manager.requestReviewFlow() } answers { + if (hanging) hangingTask() else Tasks.forResult(reviewInfo()) + } + val tool = tool() + val states = collectStates(tool) + + advanceBy(REQUEST_TIMEOUT.multipliedBy(2)) + + // Our timeout does not cancel the Play Task: an in-round retry would stack concurrent + // quota-consuming requests against a service that is already hung. + verify(exactly = 1) { manager.requestReviewFlow() } + states.last().shouldAskForReview shouldBe false + + hanging = false + advanceBy(PROBE_FAILURE_COOLDOWN.multipliedBy(2)) + + // Recovery comes from the next cooldown round, not from the abandoned one. + verify(exactly = 2) { manager.requestReviewFlow() } + states.last().shouldAskForReview shouldBe true + } + + @Test fun `a probe exception is still retried inside the round`() = runTest2 { + // Only a timeout abandons the round, the exception path keeps its 3 attempt budget. + every { manager.requestReviewFlow() } returnsMany listOf( + Tasks.forException(RuntimeException("Play unavailable")), + Tasks.forResult(reviewInfo()), + ) + val tool = tool() + val states = collectStates(tool) + + advanceBy(PROBE_RETRY_DELAY.multipliedBy(4)) + + verify(exactly = 2) { manager.requestReviewFlow() } + states.last().shouldAskForReview shouldBe true + } + + @Test fun `a hanging fresh request releases the lock without persisting`() = runTest2 { + var hanging = true + every { manager.requestReviewFlow() } answers { + if (hanging) hangingTask() else Tasks.forResult(reviewInfo()) + } + every { manager.launchReviewFlow(any(), any()) } returns launchOk() + val tool = tool() + val activity = activity() + + tool.reviewNow(activity) + + // A hang is not user intent: no launch, no bookkeeping, the next tap has to be able to retry. + verify(exactly = 0) { manager.launchReviewFlow(any(), any()) } + coVerify(exactly = 0) { lastDismissedMock.update(any()) } + coVerify(exactly = 0) { reviewedAtMock.update(any()) } + + hanging = false + tool.reviewNow(activity) + + // Without the timeout the single-flight lock would still be held by the first tap. + verify(exactly = 1) { manager.launchReviewFlow(activity, any()) } + } + + @Test fun `a hanging launch releases the lock without persisting`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) + var hanging = true + every { manager.launchReviewFlow(any(), any()) } answers { + if (hanging) hangingTask() else launchOk() + } + val tool = tool() + val activity = activity() + + tool.reviewNow(activity) + + // The outcome is unknown, so neither the review nor the snooze may be recorded, and the + // duration heuristic must not treat the timeout as a completed review. + coVerify(exactly = 0) { reviewedAtMock.update(any()) } + coVerify(exactly = 0) { lastDismissedMock.update(any()) } + + hanging = false + tool.reviewNow(activity) + + verify(exactly = 2) { manager.launchReviewFlow(activity, any()) } + } + + @Test fun `a dismiss racing the fresh request aborts the launch`() = runTest2 { + val tool = tool() + every { manager.requestReviewFlow() } answers { + // The user hits "maybe later" while Play is still answering the review tap. + runBlocking { tool.dismiss() } + Tasks.forResult(reviewInfo()) + } + every { manager.launchReviewFlow(any(), any()) } returns launchOk() + + tool.reviewNow(activity()) + + verify(exactly = 0) { manager.launchReviewFlow(any(), any()) } + // Only the dismiss' own write, reviewNow must not add bookkeeping on top of it. + coVerify(exactly = 1) { lastDismissedMock.update(any()) } + coVerify(exactly = 0) { reviewedAtMock.update(any()) } + } + + @Test fun `a definitive probe answer is not requested twice`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) + val tool = tool() + + val first = mutableListOf() + val firstJob = backgroundScope.launch { tool.state.collect { first += it } } + advanceBy(Duration.ofSeconds(1)) + first.last().shouldAskForReview shouldBe true + firstJob.cancelAndJoin() + + val second = mutableListOf() + val secondJob = backgroundScope.launch { tool.state.collect { second += it } } + advanceBy(Duration.ofSeconds(1)) + second.last().shouldAskForReview shouldBe true + secondJob.cancelAndJoin() + + // Play's answer holds for the rest of the process, a re-subscription must not cost quota. + verify(exactly = 1) { manager.requestReviewFlow() } + } + + @Test fun `an isNoOp answer is not requested twice either`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo(canShow = false)) + val tool = tool() + + val first = mutableListOf() + val firstJob = backgroundScope.launch { tool.state.collect { first += it } } + advanceBy(Duration.ofSeconds(1)) + first.last().shouldAskForReview shouldBe false + firstJob.cancelAndJoin() + + val second = mutableListOf() + val secondJob = backgroundScope.launch { tool.state.collect { second += it } } + advanceBy(Duration.ofSeconds(1)) + second.last().shouldAskForReview shouldBe false + secondJob.cancelAndJoin() + + // isNoOp is a verdict, not a failure: it is cached like any other answer. + verify(exactly = 1) { manager.requestReviewFlow() } + } + + @Test fun `a transient probe failure is retried after the cooldown`() = runTest2 { + var healthy = false + every { manager.requestReviewFlow() } answers { + if (healthy) Tasks.forResult(reviewInfo()) else Tasks.forException(RuntimeException("Play unavailable")) + } + val tool = tool() + val states = collectStates(tool) + + advanceBy(PROBE_FAILURE_COOLDOWN.dividedBy(2)) + + verify(exactly = 3) { manager.requestReviewFlow() } + states.last().shouldAskForReview shouldBe false + + healthy = true + advanceBy(PROBE_FAILURE_COOLDOWN) + + // A failure is not an answer, so Play gets asked again once the cooldown is over. + verify(exactly = 4) { manager.requestReviewFlow() } + states.last().shouldAskForReview shouldBe true + } + + @Test fun `the probe retry rounds are bounded`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forException(RuntimeException("Play unavailable")) + val tool = tool(hotSettings = true) + val states = collectStates(tool) + + advanceBy(PROBE_FAILURE_COOLDOWN.multipliedBy(4)) + + // The initial round plus 3 cooldown rounds, 3 attempts each, then the process gives up. + verify(exactly = 12) { manager.requestReviewFlow() } + states.last().shouldAskForReview shouldBe false + + advanceBy(PROBE_FAILURE_COOLDOWN.multipliedBy(20)) + + verify(exactly = 12) { manager.requestReviewFlow() } + + // The budget is spent for the process: an eligibility flicker restarts the probe branch, + // but it must not hand out a fresh set of rounds. + lastDismissedFlow.value = Instant.now() + advanceBy(Duration.ofSeconds(1)) + lastDismissedFlow.value = null + advanceBy(PROBE_FAILURE_COOLDOWN.multipliedBy(10)) + + verify(exactly = 12) { manager.requestReviewFlow() } + } + + @Test fun `a snooze running out flips the card on without a restart`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) + var fakeNow = BASE_NOW + val tool = tool( + upgradedAt = BASE_NOW.minus(Duration.ofDays(60)), + lastDismissed = BASE_NOW.minus(Duration.ofDays(13)), + ).apply { nowProvider = { fakeNow } } + val states = collectStates(tool) + + advanceBy(Duration.ofSeconds(1)) + states.last().shouldAskForReview shouldBe false + + // The snooze ends a day later: the scheduled re-evaluation has to re-read the clock. + fakeNow = BASE_NOW.plus(Duration.ofDays(2)) + advanceBy(Duration.ofDays(2)) + + states.last().shouldAskForReview shouldBe true + } + + @Test fun `the pro grace period boundary is strict`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) + var fakeNow = BASE_NOW + val upgradedAt = BASE_NOW.minus(Duration.ofDays(21)) + + val atBoundary = tool(upgradedAt = upgradedAt).apply { nowProvider = { fakeNow } } + val atBoundaryStates = collectStates(atBoundary) + advanceBy(Duration.ofSeconds(1)) + // 21 days have to have passed, not just been reached + atBoundaryStates.last().shouldAskForReview shouldBe false + + // A second instance because nothing is pending on the first one: the wake schedule only + // keeps boundaries strictly after "now", and this one is exactly "now". + fakeNow = BASE_NOW.plus(Duration.ofSeconds(1)) + val pastBoundary = tool(upgradedAt = upgradedAt).apply { nowProvider = { fakeNow } } + val pastBoundaryStates = collectStates(pastBoundary) + advanceBy(Duration.ofSeconds(1)) + + pastBoundaryStates.last().shouldAskForReview shouldBe true + } + + @Test fun `a new dismiss reschedules the boundary re-evaluation`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) + var fakeNow = BASE_NOW + val tool = tool( + upgradedAt = BASE_NOW.minus(Duration.ofDays(60)), + lastDismissed = BASE_NOW.minus(Duration.ofDays(13)), + hotSettings = true, + ).apply { nowProvider = { fakeNow } } + val states = collectStates(tool) + + advanceBy(Duration.ofSeconds(1)) + states.last().shouldAskForReview shouldBe false + + // Dismissed again while the old snooze end was still scheduled + lastDismissedFlow.value = BASE_NOW + advanceBy(Duration.ofSeconds(1)) + + fakeNow = BASE_NOW.plus(Duration.ofDays(2)) + advanceBy(Duration.ofDays(3)) + // The stale boundary must not flip the card back on, the new snooze governs now + states.last().shouldAskForReview shouldBe false + + fakeNow = BASE_NOW.plus(Duration.ofDays(15)) + advanceBy(Duration.ofDays(15)) + + states.last().shouldAskForReview shouldBe true + } + + @Test fun `a clock that moved backwards re-evaluates instead of flipping`() = runTest2 { + every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo()) + var fakeNow = BASE_NOW + val tool = tool( + upgradedAt = BASE_NOW.minus(Duration.ofDays(60)), + lastDismissed = BASE_NOW.minus(Duration.ofDays(13)), + ).apply { nowProvider = { fakeNow } } + val states = collectStates(tool) + + advanceBy(Duration.ofSeconds(1)) + states.last().shouldAskForReview shouldBe false + + // The device clock moved back: the wake fires on schedule, but the snooze is still running + fakeNow = BASE_NOW.minus(Duration.ofDays(5)) + advanceBy(Duration.ofDays(30)) + + states.last().shouldAskForReview shouldBe false + verify(exactly = 0) { manager.requestReviewFlow() } + + // Rescheduling is capped, so a backwards clock cannot keep the process waking forever + fakeNow = BASE_NOW.plus(Duration.ofDays(2)) + advanceBy(Duration.ofDays(30)) + + states.last().shouldAskForReview shouldBe false + } + + companion object { + private val BASE_NOW: Instant = Instant.parse("2024-06-01T12:00:00Z") + private val PROBE_RETRY_DELAY: Duration = Duration.ofSeconds(1) + private val PROBE_FAILURE_COOLDOWN: Duration = Duration.ofMinutes(5) + private val REQUEST_TIMEOUT: Duration = Duration.ofSeconds(5) + private val LAUNCH_TIMEOUT: Duration = Duration.ofMinutes(1) + } }