修复回忆录图片重试状态透传与前端展示
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user