Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2026 Tyler Stapler
// SPDX-License-Identifier: Elastic-2.0
package dev.stapler.stelekit.tags

import dev.stapler.stelekit.error.DomainError
import dev.stapler.stelekit.voice.LlmFormatterProvider
import dev.stapler.stelekit.voice.LlmResult
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* Direct regression coverage for [LlmTagProvider.suggestTags]'s [DomainError] mapping —
* in particular the bug named in requirements.md's Root Cause section, where the
* `retryable` signal on [LlmResult.Failure.OnDeviceUnavailable] was silently dropped when
* mapped to [DomainError.NetworkError.RequestFailed], collapsing every on-device-unavailable
* failure (including transient "still downloading" states) to non-retryable.
*/
class LlmTagProviderTest {

@Test
fun `suggestTags maps a retryable OnDeviceUnavailable to a retryable RequestFailed`() = runTest {
val formatter = LlmFormatterProvider { _, _ ->
LlmResult.Failure.OnDeviceUnavailable(
"Downloading on-device model — this may take a few minutes",
retryable = true,
)
}
val provider = LlmTagProvider(formatter, timeoutSeconds = 5)

val result = provider.suggestTags(
TagSuggestionRequest(
blockUuid = "block-1",
blockContent = "Kotlin is great",
pageVocabulary = listOf("Kotlin"),
),
)

assertTrue(result.isLeft())
assertEquals(
DomainError.NetworkError.RequestFailed(
message = "Downloading on-device model — this may take a few minutes",
retryable = true,
),
result.leftOrNull(),
)
}

/**
* Regression coverage for the same retryable-dropping bug class, this time triggered by a
* plain [LlmResult.Failure.NetworkError] rather than [LlmResult.Failure.OnDeviceUnavailable].
* A transient network error is a textbook retryable case — collapsing it to
* `retryable = false` reproduces this PR's "frozen, no way forward" bug for a different
* trigger (no retry button, and requestSuggestions' cache check treats it as terminal).
*/
@Test
fun `suggestTags maps a NetworkError to a retryable RequestFailed`() = runTest {
val formatter = LlmFormatterProvider { _, _ -> LlmResult.Failure.NetworkError }
val provider = LlmTagProvider(formatter, timeoutSeconds = 5)

val result = provider.suggestTags(
TagSuggestionRequest(
blockUuid = "block-1",
blockContent = "Kotlin is great",
pageVocabulary = listOf("Kotlin"),
),
)

assertTrue(result.isLeft())
assertEquals(
DomainError.NetworkError.RequestFailed(
message = "Network error",
retryable = true,
),
result.leftOrNull(),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 Tyler Stapler
// SPDX-License-Identifier: Elastic-2.0
package dev.stapler.stelekit.tags

import dev.stapler.stelekit.llm.LlmProviderAvailability
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlin.time.Clock

class TagAvailabilityPollerTest {

@Test
fun `pollUntilAvailable returns immediately once Available is observed`() = runTest {
var calls = 0
val result = TagAvailabilityPoller.pollUntilAvailable(
checkAvailability = { calls++; if (calls >= 3) LlmProviderAvailability.Available
else LlmProviderAvailability.Preparing("downloading") },
onStatusUpdate = {},
)
assertIs<LlmProviderAvailability.Available>(result)
assertEquals(3, calls)
}

@Test
fun `pollUntilAvailable returns retryable Unavailable when deadline is reached`() = runTest {
val result = TagAvailabilityPoller.pollUntilAvailable(
checkAvailability = { LlmProviderAvailability.Preparing("still downloading") },
onStatusUpdate = {},
deadlineMs = 12_000L,
intervalMs = 4_000L,
)
assertIs<LlmProviderAvailability.Unavailable>(result)
assertTrue(result.retryable)
assertEquals("Taking longer than expected", result.reason)
}

@Test
fun `pollUntilAvailable stops immediately on non-retryable Unavailable`() = runTest {
var calls = 0
val result = TagAvailabilityPoller.pollUntilAvailable(
checkAvailability = { calls++; LlmProviderAvailability.Unavailable("Not supported", retryable = false) },
onStatusUpdate = { fail("must not push a status update for a permanent failure") },
)
assertIs<LlmProviderAvailability.Unavailable>(result)
assertFalse(result.retryable)
assertEquals(1, calls)
}

@Test
fun `pollUntilAvailable escalates the caption exactly once after 45s`() = runTest {
val updates = mutableListOf<LlmSuggestionStatus>()
TagAvailabilityPoller.pollUntilAvailable(
checkAvailability = { LlmProviderAvailability.Preparing("still downloading") },
onStatusUpdate = { updates += it },
deadlineMs = 120_000L,
intervalMs = 4_000L,
escalationThresholdMs = 45_000L,
)
val pendingUpdates = updates.filterIsInstance<LlmSuggestionStatus.Pending>()
assertEquals(1, pendingUpdates.size, "caption must change exactly once before the terminal state")
assertEquals(
"Still downloading — this can take a few minutes the first time.",
pendingUpdates.single().caption,
)
}

@Test
fun `pollUntilAvailable treats a thrown checkAvailability as a transient tick and keeps polling`() = runTest {
var calls = 0
val result = TagAvailabilityPoller.pollUntilAvailable(
checkAvailability = {
calls++
when (calls) {
2 -> throw IllegalStateException("simulated AICore binder crash")
3 -> LlmProviderAvailability.Available
else -> LlmProviderAvailability.Preparing("downloading")
}
},
onStatusUpdate = {},
)
assertIs<LlmProviderAvailability.Available>(result)
assertEquals(3, calls)
}

@Test
fun `pollUntilAvailable measures elapsed time from startedAtOverride, not from invocation time`() = runTest {
// startedAtOverride must be anchored to a REAL Clock.System.now() read, not a synthetic
// epoch value: pollUntilAvailable computes initialElapsedMs as
// Clock.System.now().toEpochMilliseconds() - startedAtOverride (runTest virtualizes
// delay(), not Clock.System), so a fictional "now" here would make initialElapsedMs
// enormous and the while-loop's first condition check would fail immediately — zero
// ticks, no regression protection at all for the exact resumed-poll arithmetic this
// test exists to cover (this was itself a bug in this test, caught in code review).
val startedAtOverride = Clock.System.now().toEpochMilliseconds() - 90_000L // "downloading" for 90s already

var calls = 0
val updates = mutableListOf<LlmSuggestionStatus>()
val result = TagAvailabilityPoller.pollUntilAvailable(
checkAvailability = { calls++; LlmProviderAvailability.Preparing("still downloading") },
onStatusUpdate = { updates += it },
deadlineMs = 120_000L,
intervalMs = 4_000L,
escalationThresholdMs = 45_000L,
startedAtOverride = startedAtOverride,
)
assertIs<LlmProviderAvailability.Unavailable>(result)
assertTrue(result.retryable)
// 90s already elapsed + 120s deadline means only 30s of *this* invocation's ticks run
// (30_000 / 4_000 = 7.5 -> 8 ticks), not a fresh 120s/30 ticks.
assertEquals(8, calls, "should stop after ~30s of remaining budget (8 ticks), not a fresh 120s/30 ticks")
assertTrue(updates.none { it is LlmSuggestionStatus.Pending },
"no escalation update should fire mid-loop — 90s already exceeds the 45s threshold " +
"before the loop even starts, so 'escalated' starts true and the caller is expected " +
"to have already shown the escalated caption itself")
}
}
Loading
Loading