修复回忆录图片重试状态透传与前端展示

This commit is contained in:
Kevin
2026-03-11 15:20:59 +08:00
parent 4b4dea8a45
commit 305e5dcde9
18 changed files with 724 additions and 64 deletions

View File

@@ -1,8 +1,8 @@
package com.huaga.life_echo.ui.components.memoir
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertDoesNotExist
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import com.huaga.life_echo.network.models.ChapterContentDto
@@ -70,6 +70,7 @@ class ChapterReadingImageBlocksTest {
prompt = null,
url = null,
status = "failed",
retryable = false,
provider = "liblib",
style = "watercolor",
size = "1024x1024",
@@ -82,6 +83,44 @@ class ChapterReadingImageBlocksTest {
composeRule.setContent { ChapterReadingView(chapter = chapter) }
composeRule.onNodeWithTag("memoir-image-error-0").assertDoesNotExist()
composeRule.onNodeWithTag("memoir-image-error-0").assertIsDisplayed()
composeRule.onNodeWithText("图片生成失败,暂不可恢复").assertIsDisplayed()
}
@Test
fun chapterReadingView_showsUnavailablePlaceholder_forCompletedImageWithoutUrl() {
val chapter = ChapterContentDto(
id = "chapter-1",
title = "童年的夏天",
content = "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}",
orderIndex = 0,
status = "completed",
category = "childhood",
pageCount = null,
updatedAt = 0L,
quotes = emptyList(),
images = listOf(
ChapterImageDto(
index = 0,
placeholder = "{{{{IMAGE:南方小镇的青石板路}}}}",
description = "南方小镇的青石板路",
prompt = null,
url = null,
status = "completed",
retryable = null,
provider = "liblib",
style = "watercolor",
size = "1024x1024",
error = "image delivery unavailable",
created_at = null,
updated_at = null,
)
),
)
composeRule.setContent { ChapterReadingView(chapter = chapter) }
composeRule.onNodeWithTag("memoir-image-error-0").assertIsDisplayed()
composeRule.onNodeWithText("图片暂不可用").assertIsDisplayed()
}
}

View File

@@ -27,6 +27,7 @@ data class ChapterImageDto(
val prompt: String? = null,
val url: String? = null,
val status: String,
val retryable: Boolean? = null,
val provider: String? = null,
val style: String? = null,
val size: String? = null,

View File

@@ -2,6 +2,7 @@ package com.huaga.life_echo.ui.components.memoir
import com.huaga.life_echo.network.models.ChapterImageDto
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_COMPLETED
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_FAILED
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PENDING
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PROCESSING
@@ -27,12 +28,7 @@ fun splitMemoirContent(content: String, images: List<ChapterImageDto>): List<Mem
val parts = remaining.split(placeholder, limit = 2)
val before = stripImagePlaceholders(parts.first())
if (before.isNotBlank()) blocks += MemoirContentBlock.Text(before)
if (image.status == MEMOIR_IMAGE_STATUS_COMPLETED && !image.url.isNullOrBlank()) {
blocks += MemoirContentBlock.Image(image)
} else if (
image.status == MEMOIR_IMAGE_STATUS_PENDING ||
image.status == MEMOIR_IMAGE_STATUS_PROCESSING
) {
if (shouldRenderMemoirImageBlock(image)) {
blocks += MemoirContentBlock.Image(image)
}
remaining = parts.getOrElse(1) { "" }
@@ -41,3 +37,13 @@ fun splitMemoirContent(content: String, images: List<ChapterImageDto>): List<Mem
if (trailingText.isNotBlank()) blocks += MemoirContentBlock.Text(trailingText)
return blocks
}
internal fun shouldRenderMemoirImageBlock(image: ChapterImageDto): Boolean {
return when (image.status) {
MEMOIR_IMAGE_STATUS_COMPLETED,
MEMOIR_IMAGE_STATUS_PENDING,
MEMOIR_IMAGE_STATUS_PROCESSING,
MEMOIR_IMAGE_STATUS_FAILED -> true
else -> false
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import com.huaga.life_echo.network.models.ChapterImageDto
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_COMPLETED
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_FAILED
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PENDING
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PROCESSING
import com.huaga.life_echo.ui.theme.AppTypography
@@ -70,6 +71,39 @@ private fun MemoirImageLoadingPlaceholder(
}
}
@Composable
private fun MemoirImageStatusPlaceholder(
image: ChapterImageDto,
text: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(memoirImageAspectRatio(image.size))
.clip(RoundedCornerShape(16.dp))
.background(LightPurple.copy(alpha = 0.16f))
.testTag("memoir-image-error-${image.index}"),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
fontSize = AppTypography.bodyMedium,
color = SlatePurple.copy(alpha = 0.72f),
)
}
}
internal fun memoirImageFailureText(image: ChapterImageDto): String {
return when {
image.status == MEMOIR_IMAGE_STATUS_FAILED && image.retryable == true ->
"图片生成失败,稍后重试"
image.status == MEMOIR_IMAGE_STATUS_FAILED ->
"图片生成失败,暂不可恢复"
else -> "图片暂不可用"
}
}
@Composable
fun MemoirInlineImage(
image: ChapterImageDto,
@@ -77,36 +111,51 @@ fun MemoirInlineImage(
modifier: Modifier = Modifier,
) {
when (image.status) {
MEMOIR_IMAGE_STATUS_COMPLETED -> SubcomposeAsyncImage(
model = image.url,
contentDescription = image.description,
contentScale = ContentScale.FillWidth,
loading = {
MemoirImageLoadingPlaceholder(
MEMOIR_IMAGE_STATUS_COMPLETED -> {
if (image.url.isNullOrBlank()) {
MemoirImageStatusPlaceholder(
image = image,
text = memoirImageFailureText(image),
modifier = modifier,
text = "图片加载中…",
)
},
error = {
MemoirImageLoadingPlaceholder(
image = image,
modifier = modifier,
text = "图片暂不可用",
} else {
SubcomposeAsyncImage(
model = image.url,
contentDescription = image.description,
contentScale = ContentScale.FillWidth,
loading = {
MemoirImageLoadingPlaceholder(
image = image,
modifier = modifier,
text = "图片加载中…",
)
},
error = {
MemoirImageStatusPlaceholder(
image = image,
text = "图片暂不可用",
modifier = modifier,
)
},
modifier = modifier
.fillMaxWidth()
.aspectRatio(memoirImageAspectRatio(image.size))
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.testTag("memoir-image-${image.index}")
)
},
modifier = modifier
.fillMaxWidth()
.aspectRatio(memoirImageAspectRatio(image.size))
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.testTag("memoir-image-${image.index}")
)
}
}
MEMOIR_IMAGE_STATUS_PENDING, MEMOIR_IMAGE_STATUS_PROCESSING -> MemoirImageLoadingPlaceholder(
image = image,
modifier = modifier,
text = "图片生成中…",
)
MEMOIR_IMAGE_STATUS_FAILED -> MemoirImageStatusPlaceholder(
image = image,
text = memoirImageFailureText(image),
modifier = modifier,
)
else -> Unit
}
}

View File

@@ -70,6 +70,7 @@ class MemoirContentBlocksTest {
prompt = null,
url = null,
status = "failed",
retryable = false,
provider = "liblib",
style = "watercolor",
size = "1024x1024",
@@ -80,10 +81,38 @@ class MemoirContentBlocksTest {
)
)
assertFalse(blocks.any { it is MemoirContentBlock.Image })
val combinedText = blocks.filterIsInstance<MemoirContentBlock.Text>().joinToString("\n") { it.content }
assertFalse(combinedText.contains("IMAGE:"))
assertTrue(combinedText.contains("开头"))
assertTrue(combinedText.contains("结尾"))
assertTrue(blocks.any { it is MemoirContentBlock.Image })
val imageBlock = blocks[1] as MemoirContentBlock.Image
assertEquals("failed", imageBlock.image.status)
assertEquals(false, imageBlock.image.retryable)
}
@Test
fun splitMemoirContent_keepsCompletedImageBlock_whenSignedUrlIsUnavailable() {
val blocks = splitMemoirContent(
content = "开头。\n\n{{{{IMAGE:签名失败的图}}}}\n\n结尾。",
images = listOf(
ChapterImageDto(
index = 0,
placeholder = "{{{{IMAGE:签名失败的图}}}}",
description = "签名失败的图",
prompt = null,
url = null,
status = "completed",
retryable = null,
provider = "liblib",
style = "watercolor",
size = "1024x1024",
error = "image delivery unavailable",
created_at = null,
updated_at = null,
)
)
)
assertTrue(blocks.any { it is MemoirContentBlock.Image })
val imageBlock = blocks[1] as MemoirContentBlock.Image
assertEquals("completed", imageBlock.image.status)
assertEquals(null, imageBlock.image.url)
}
}

View File

@@ -0,0 +1,51 @@
package com.huaga.life_echo.ui.components.memoir
import com.huaga.life_echo.network.models.ChapterImageDto
import org.junit.Assert.assertEquals
import org.junit.Test
class MemoirInlineImageStateTest {
@Test
fun memoirImageFailureText_returnsRetryHint_forRetryableFailures() {
val image = image(status = "failed", retryable = true)
assertEquals("图片生成失败,稍后重试", memoirImageFailureText(image))
}
@Test
fun memoirImageFailureText_returnsPermanentHint_forNonRetryableFailures() {
val image = image(status = "failed", retryable = false)
assertEquals("图片生成失败,暂不可恢复", memoirImageFailureText(image))
}
@Test
fun memoirImageFailureText_returnsUnavailableHint_forCompletedImageWithoutUrl() {
val image = image(status = "completed", retryable = null, url = null)
assertEquals("图片暂不可用", memoirImageFailureText(image))
}
private fun image(
status: String,
retryable: Boolean?,
url: String? = null,
): ChapterImageDto {
return ChapterImageDto(
index = 0,
placeholder = "{{IMAGE:南方小镇的青石板路}}",
description = "南方小镇的青石板路",
prompt = null,
url = url,
status = status,
retryable = retryable,
provider = "liblib",
style = "watercolor",
size = "1024x1024",
error = null,
created_at = null,
updated_at = null,
)
}
}

View File

@@ -107,7 +107,27 @@ class MemoirImagePollingTest {
)
}
private fun chapterWithImages(status: String, imageCount: Int = 1): ChapterDto {
@Test
fun shouldContinueMemoirImagePolling_returnsFalse_forRetryableFailedImages() {
val chapters = listOf(
chapterWithImages("failed", imageCount = 1, retryable = true),
)
assertFalse(
shouldContinueMemoirImagePolling(
chapters = chapters,
pollStartedAtMs = 0L,
nowMs = 1L,
maxObservedPendingImages = 1L,
)
)
}
private fun chapterWithImages(
status: String,
imageCount: Int = 1,
retryable: Boolean? = null,
): ChapterDto {
return ChapterDto(
id = "chapter-$status",
title = "title-$status",
@@ -123,6 +143,7 @@ class MemoirImagePollingTest {
prompt = null,
url = if (status == "completed") "https://cos.example.com/$status-$index.png" else null,
status = status,
retryable = retryable,
provider = "liblib",
style = "memoir",
size = "1024x1024",