fix(ui): 进度展示语义化与失败可见性

修复备份工具用户判断数据安全时的多个误导问题:

- 单 app 完成不再 emit "done",改用 "appdone" → 显示"已完成"
  原行为:50 个 app 备份过程中 UI 反复闪"完成",用户易误判结束、杀进程
- restic 恢复接入 onProgress:解析"恢复进度: N%",进度条动起来
  原行为:GB 级快照下载时 UI 卡死在 0/N,像挂掉
- 失败时进度条/计数走 error 色,progressCurrent 只算成功数
  原行为:3/10 成功也显示"完成 (10/10)",掩盖 7 个失败
- 流式备份正则放宽到 (\d{1,3})(?:\.\d+)?% + coerceIn(0,1)
  原行为:restic 输出"100%"不匹配,最后一步反馈丢失
- restic 恢复失败清空 selectedSnapshot/packages,避免半残状态
- 抽公共 ProgressBlock 组件,BackupScreen/RestoreScreen 各 65 行重复 → 1 个调用
- catch/finally 完整重置 progress 字段
- 新增 StageDisplayNameTest(11 个测试)含 partial≠done 回归
This commit is contained in:
sakuradairong
2026-06-17 02:59:04 +08:00
parent 73aff16a99
commit bb0caf47d8
7 changed files with 423 additions and 106 deletions

View File

@@ -36,7 +36,7 @@ object BackupOperation {
val current: Int,
val total: Int,
val packageName: String,
val stage: String, // "apk", "data", "obb", "ssaid", "done"
val stage: String, // "apk", "data", "obb", "ssaid", "appdone" (per-app finish), "done" (reserved for overall)
val message: String,
)
@@ -189,7 +189,7 @@ object BackupOperation {
failAtomic.incrementAndGet()
val pkg = app.packageName.value
Log.e(TAG, "backupApps: $pkg backup failed: ${e.message}", e)
emit(BackupProgress(index + 1, totalCount, pkg, "done", "备份失败: ${e.message}"))
emit(BackupProgress(index + 1, totalCount, pkg, "appdone", "备份失败: ${e.message}"))
}
}
}.awaitAll()
@@ -350,7 +350,7 @@ object BackupOperation {
userDeSize = udResult.second
if (udResult.first == null) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "数据备份失败"))
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "数据备份失败"))
return
}
}
@@ -366,7 +366,7 @@ object BackupOperation {
obbSize = BackupAppDataOps.backupObb(pkgName, appDir, config.compressionMethod)
if (obbSize == null) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "OBB 备份失败"))
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "OBB 备份失败"))
return
}
}
@@ -422,7 +422,7 @@ object BackupOperation {
)
successAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "完成"))
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "完成"))
}
internal suspend fun buildAppDetailsJson(

View File

@@ -28,7 +28,7 @@ object RestoreOperation {
val current: Int,
val total: Int,
val packageName: String,
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "appdone" (per-app finish), "done" (reserved for overall)
val message: String,
)
@@ -114,7 +114,7 @@ object RestoreOperation {
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
if (!dirExists) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "备份目录不存在"))
emit(RestoreProgress(index + 1, packages.size, pkg, "appdone", "备份目录不存在"))
return@withPermit
}
@@ -125,7 +125,7 @@ object RestoreOperation {
if (!installed) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "安装失败"))
emit(RestoreProgress(index + 1, packages.size, pkg, "appdone", "安装失败"))
return@withPermit
}
@@ -140,7 +140,7 @@ object RestoreOperation {
val dataOk = RestoreAppDataOps.restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
if (!dataOk) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "数据恢复失败"))
emit(RestoreProgress(index + 1, packages.size, pkg, "appdone", "数据恢复失败"))
return@withPermit
}
@@ -170,7 +170,7 @@ object RestoreOperation {
RestoreAppDataOps.fixDataOwnership(pkg, userId) { pkgName -> resolveAppUid(pkgName) }
successAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "完成"))
emit(RestoreProgress(index + 1, packages.size, pkg, "appdone", "完成"))
}
}
}

View File

@@ -76,47 +76,18 @@ fun BackupScreen(viewModel: BackupViewModel = viewModel()) {
}
}
// ── Status with progress bar ──
if (state.isRunning) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// 进度条
LinearProgressIndicator(
progress = { state.progressPercent / 100f },
modifier = Modifier.fillMaxWidth(),
)
// 状态文本
Text(
text = state.statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// ETA 和详细信息
if (state.etaSeconds > 0) {
Text(
text = "预计剩余: ${formatEta(state.etaSeconds)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
if (state.currentStage.isNotEmpty()) {
Text(
text = "阶段: ${state.currentStage}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
Text(
text = state.statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
// ── Progress ──
ProgressBlock(
isRunning = state.isRunning,
statusText = state.statusText,
progressCurrent = state.progressCurrent,
progressTotal = state.progressTotal,
progressStage = state.progressStage,
progressPackageName = state.progressPackageName,
progressMessage = state.progressMessage,
progressPercent = state.progressPercent,
stageDisplayName = ::backupStageDisplayName,
)
// ── App list ──
LazyColumn(
@@ -193,20 +164,3 @@ private fun AppListItem(
}
}
}
/**
* 格式化 ETA 为人类可读的字符串。
*/
private fun formatEta(seconds: Long): String {
if (seconds <= 0) return "计算中..."
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val secs = seconds % 60
return when {
hours > 0 -> "${hours}小时${minutes}${secs}"
minutes > 0 -> "${minutes}${secs}"
else -> "${secs}"
}
}

View File

@@ -42,11 +42,13 @@ data class BackupUiState(
val statusText: String = "请先扫描应用",
val isRunning: Boolean = false,
val isScanning: Boolean = false,
// 进度相关字段
val progressPercent: Float = 0f,
val etaSeconds: Long = 0,
val currentStage: String = "",
val currentApp: String = "",
// ── 结构化进度字段 ──
val progressCurrent: Int = 0,
val progressTotal: Int = 0,
val progressStage: String = "", // "apk"/"data"/"obb"/"ssaid"/"appdone"(per-app) /"restic"/"done"/"partial"
val progressPackageName: String = "",
val progressMessage: String = "",
val progressPercent: Float? = null, // restic 百分比0.0~1.0null 表示不确定
)
/** 备份操作的一次性事件。 */
@@ -185,7 +187,18 @@ class BackupViewModel(
val toBackup = s.allApps.filter { it.packageName.value in s.selectedApps }
if (toBackup.isEmpty()) return
_state.update { it.copy(isRunning = true, statusText = "开始备份 ${toBackup.size} 个应用…") }
_state.update {
it.copy(
isRunning = true,
statusText = "开始备份 ${toBackup.size} 个应用…",
progressCurrent = 0,
progressTotal = toBackup.size,
progressStage = "",
progressPackageName = "",
progressMessage = "",
progressPercent = null,
)
}
currentJob =
viewModelScope.launch {
@@ -203,7 +216,6 @@ class BackupViewModel(
// 2. 执行备份
val outputDir = File(s.config.outputPath.ifEmpty { context.filesDir.absolutePath })
val backupProgressTracker = com.example.androidbackupgui.backup.BackupProgressTracker(toBackup.size)
val backupResult =
withContext(Dispatchers.IO) {
BackupOperation.backupApps(
@@ -214,31 +226,30 @@ class BackupViewModel(
userId = s.config.backupUserId.toString(),
noDataBackup = s.excludeDataFromBackup,
onProgress = { progress ->
backupProgressTracker.startApp(progress.packageName)
backupProgressTracker.updateStage(progress.stage, progress.message)
if (progress.stage == "done") {
backupProgressTracker.completeApp()
}
val progressInfo = backupProgressTracker.getProgress()
_state.update {
it.copy(
statusText = progressInfo.message,
progressPercent = progressInfo.percent,
etaSeconds = progressInfo.etaSeconds,
currentStage = progressInfo.stage,
currentApp = progressInfo.packageName,
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}",
progressCurrent = progress.current,
progressTotal = progress.total,
progressStage = progress.stage,
progressPackageName = progress.packageName,
progressMessage = progress.message,
progressPercent = null,
)
}
},
)
}
val failed = backupResult.failCount
_state.update {
it.copy(
statusText = "备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s",
progressPercent = 100f,
etaSeconds = 0,
currentStage = "完成",
currentApp = "",
statusText = "备份${if (failed > 0) "完成(部分失败)" else "完成"}!成功: ${backupResult.successCount} 失败: $failed 耗时: ${backupResult.elapsedMs / 1000}s",
progressCurrent = backupResult.successCount,
progressTotal = toBackup.size,
progressStage = if (failed > 0) "partial" else "done",
progressPackageName = "",
progressMessage = if (failed > 0) "失败 $failed" else "完成",
progressPercent = null,
)
}
@@ -270,9 +281,21 @@ class BackupViewModel(
append("\n建议: ${errorInfo.suggestion}")
}
}
_state.update { it.copy(statusText = errorMessage) }
_state.update {
it.copy(
statusText = errorMessage,
progressStage = "partial",
progressMessage = e.message ?: "异常",
progressPercent = null,
)
}
} finally {
_state.update { it.copy(isRunning = false) }
_state.update {
it.copy(
isRunning = false,
progressPercent = null,
)
}
try {
context.startService(Intent(context, BackupService::class.java).apply { action = ACTION_STOP_BACKUP })
} catch (_: Exception) {
@@ -312,7 +335,26 @@ class BackupViewModel(
backendUser = s.config.resticBackendUser,
backendPass = backendPass,
backendShare = s.config.resticBackendShare,
onProgress = { msg -> _state.update { it.copy(statusText = msg) } },
onProgress = { msg ->
// 流式备份进度消息包含百分比信息,尝试解析。
// 兼容整数 "100%" 与小数 "12.34%",并 coerce 到 [0,1]。
val pct =
Regex("""(\d{1,3})(?:\.\d+)?%""")
.find(msg)
?.groupValues
?.get(1)
?.toFloatOrNull()
?.div(100f)
?.coerceIn(0f, 1f)
_state.update {
it.copy(
statusText = msg,
progressStage = "restic",
progressMessage = msg,
progressPercent = pct,
)
}
},
).let { result ->
when (result) {
is AppResult.Success -> {
@@ -327,7 +369,14 @@ class BackupViewModel(
}
is AppResult.Failure -> {
_state.update { it.copy(statusText = "流式备份失败: ${result.errorOrNull()?.message}") }
_state.update {
it.copy(
statusText = "流式备份失败: ${result.errorOrNull()?.message}",
progressStage = "partial",
progressMessage = "上传失败",
progressPercent = null,
)
}
}
}
}
@@ -354,6 +403,9 @@ class BackupViewModel(
progress.filesDone,
progress.totalFiles,
),
progressStage = "restic",
progressMessage = "上传中: %.0f%%".format(progress.percentDone * 100),
progressPercent = progress.percentDone.toFloat(),
)
}
}
@@ -372,7 +424,14 @@ class BackupViewModel(
}
is AppResult.Failure -> {
_state.update { it.copy(statusText = "restic 快照失败: ${result.errorOrNull()?.message}") }
_state.update {
it.copy(
statusText = "restic 快照失败: ${result.errorOrNull()?.message}",
progressStage = "partial",
progressMessage = "上传失败",
progressPercent = null,
)
}
}
}
}

View File

@@ -0,0 +1,139 @@
package com.example.androidbackupgui.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* 备份/恢复通用结构化进度展示组件,三态:
* - [isRunning] && [progressTotal] > 0显示阶段名 + 计数 + 进度条 + 消息行
* - [isRunning] && 无结构化进度:圆形 spinner + [statusText]
* - !isRunning仅显示 [statusText]
*
* 阶段名通过 [stageDisplayName] 映射,由调用方提供(备份/恢复各有自己的映射表,
* 见 [backupStageDisplayName] / [restoreStageDisplayName])。
*
* 失败语义:当 [progressStage] 为 "partial" 时进度条与计数使用 error 色,
* 用于让用户在多个应用部分失败时立刻察觉(备份工具的关键诉求)。
*
* @param progressPercent 0.0~1.0 的确定百分比null 表示按计数计算
*/
@Composable
fun ProgressBlock(
isRunning: Boolean,
statusText: String,
progressCurrent: Int,
progressTotal: Int,
progressStage: String,
progressPackageName: String,
progressMessage: String,
progressPercent: Float?,
stageDisplayName: (String) -> String,
modifier: Modifier = Modifier,
) {
if (isRunning && progressTotal > 0) {
val isError = progressStage == "partial"
val counterColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
val trackColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
val computedFraction =
(progressPercent ?: (progressCurrent.toFloat() / progressTotal.coerceAtLeast(1)))
.coerceIn(0f, 1f)
Column(modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text =
stageDisplayName(progressStage) +
if (progressPackageName.isNotEmpty()) "$progressPackageName" else "",
style = MaterialTheme.typography.bodySmall,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "$progressCurrent/$progressTotal",
style = MaterialTheme.typography.labelSmall,
color = counterColor,
)
}
Spacer(Modifier.height(4.dp))
LinearProgressIndicator(
progress = { computedFraction },
color = trackColor,
modifier = Modifier.fillMaxWidth().height(6.dp),
)
if (progressMessage.isNotEmpty()) {
Spacer(Modifier.height(2.dp))
Text(
text = progressMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
} else if (isRunning) {
Row(
modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
}
/** 备份阶段标识 → 用户友好中文名。pure function便于单元测试。 */
fun backupStageDisplayName(stage: String): String =
when (stage) {
"apk" -> "备份 APK"
"data" -> "备份数据"
"obb" -> "备份 OBB"
"ssaid" -> "备份 SSAID"
"appdone" -> "已完成"
"restic" -> "上传至 Restic"
"done" -> "完成"
"partial" -> "部分完成"
else -> stage.ifEmpty { "处理中" }
}
/** 恢复阶段标识 → 用户友好中文名。pure function便于单元测试。 */
fun restoreStageDisplayName(stage: String): String =
when (stage) {
"install" -> "安装 APK"
"data" -> "恢复数据"
"obb" -> "恢复 OBB"
"ssaid" -> "恢复 SSAID"
"permissions" -> "恢复权限"
"appdone" -> "已完成"
"done" -> "完成"
"partial" -> "部分完成"
else -> stage.ifEmpty { "处理中" }
}

View File

@@ -43,6 +43,13 @@ fun RestoreScreen() {
var statusText by remember { mutableStateOf("请选择备份源") }
var showSnapshotPicker by remember { mutableStateOf(false) }
var availableSnapshots by remember { mutableStateOf<List<ResticWrapper.ResticSnapshot>>(emptyList()) }
// ── 结构化进度状态 ──
var progressCurrent by remember { mutableIntStateOf(0) }
var progressTotal by remember { mutableIntStateOf(0) }
var progressStage by remember { mutableStateOf("") }
var progressPackageName by remember { mutableStateOf("") }
var progressMessage by remember { mutableStateOf("") }
var progressPercent by remember { mutableStateOf<Float?>(null) }
val configFile = remember { File(context.filesDir, "backup_settings.conf") }
// SAF directory picker for selecting external backup dir
@@ -216,12 +223,17 @@ fun RestoreScreen() {
}
}
// ── Status ──
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
// ── Progress ──
ProgressBlock(
isRunning = isRunning,
statusText = statusText,
progressCurrent = progressCurrent,
progressTotal = progressTotal,
progressStage = progressStage,
progressPackageName = progressPackageName,
progressMessage = progressMessage,
progressPercent = progressPercent,
stageDisplayName = ::restoreStageDisplayName,
)
// ── App list ──
@@ -284,6 +296,12 @@ fun RestoreScreen() {
if (toRestore.isEmpty()) return@Button
isRunning = true
statusText = "开始恢复 ${toRestore.size} 个应用…"
progressCurrent = 0
progressTotal = toRestore.size
progressStage = ""
progressPackageName = ""
progressMessage = ""
progressPercent = null
scope.launch {
try {
@@ -296,6 +314,9 @@ fun RestoreScreen() {
try {
statusText = "正在从 restic 快照恢复…"
progressStage = "restic"
progressMessage = "正在拉取快照…"
progressPercent = null
val restoreResult =
withContext(Dispatchers.IO) {
val rPw =
@@ -314,14 +335,35 @@ fun RestoreScreen() {
backendUser = config.resticBackendUser,
backendPass = rBpw,
backendShare = config.resticBackendShare,
onProgress = { msg ->
// restic restore emits "恢复进度: 12.3%" / "恢复完成: N 个文件"
statusText = msg
progressMessage = msg
val pct =
Regex("""(\d{1,3})(?:\.\d+)?%""")
.find(msg)
?.groupValues
?.get(1)
?.toFloatOrNull()
?.div(100f)
?.coerceIn(0f, 1f)
progressPercent = pct
},
)
}
if (restoreResult.isFailure) {
statusText = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
progressMessage = statusText
// 清空快照选择,避免用户在半残状态上二次操作
selectedSnapshot = null
packages = emptyList()
appInfos = emptyList()
selectedPackages = emptySet()
return@launch
}
val restoredDir = File(staging, backupPath.removePrefix("/"))
statusText = "正在从恢复的备份安装应用…"
progressPercent = null
val result =
withContext(Dispatchers.IO) {
@@ -333,16 +375,26 @@ fun RestoreScreen() {
onProgress = { progress ->
statusText =
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
progressCurrent = progress.current
progressTotal = progress.total
progressStage = progress.stage
progressPackageName = progress.packageName
progressMessage = progress.message
},
)
}
WifiManager.restore(restoredDir)
val failed = result.failCount
statusText =
buildString {
appendLine("恢复完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("恢复${if (failed > 0) "完成(部分失败)" else "完成!"}")
appendLine("成功: ${result.successCount} 失败: $failed")
append("耗时: ${result.elapsedMs / 1000}")
}
progressCurrent = result.successCount
progressStage = if (failed > 0) "partial" else "done"
progressMessage = if (failed > 0) "失败 $failed" else "完成"
progressPercent = null
} finally {
try {
staging.deleteRecursively()
@@ -361,21 +413,33 @@ fun RestoreScreen() {
onProgress = { progress ->
statusText =
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
progressCurrent = progress.current
progressTotal = progress.total
progressStage = progress.stage
progressPackageName = progress.packageName
progressMessage = progress.message
},
)
}
WifiManager.restore(dir)
val failed = result.failCount
statusText =
buildString {
appendLine("恢复完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("恢复${if (failed > 0) "完成(部分失败)" else "完成!"}")
appendLine("成功: ${result.successCount} 失败: $failed")
append("耗时: ${result.elapsedMs / 1000}")
}
progressCurrent = result.successCount
progressStage = if (failed > 0) "partial" else "done"
progressMessage = if (failed > 0) "失败 $failed" else "完成"
}
} catch (e: Exception) {
statusText = "恢复异常: ${e.message}"
progressMessage = e.message ?: "异常"
progressStage = "partial"
} finally {
isRunning = false
progressPercent = null
}
}
},

View File

@@ -0,0 +1,101 @@
package com.example.androidbackupgui.ui
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldBeEmpty
import io.kotest.property.Arb
import io.kotest.property.arbitrary.element
import io.kotest.property.arbitrary.string
import io.kotest.property.checkAll
class StageDisplayNameTest : FunSpec({
context("backupStageDisplayName") {
test("maps known backup stages to Chinese labels") {
backupStageDisplayName("apk") shouldBe "备份 APK"
backupStageDisplayName("data") shouldBe "备份数据"
backupStageDisplayName("obb") shouldBe "备份 OBB"
backupStageDisplayName("ssaid") shouldBe "备份 SSAID"
backupStageDisplayName("appdone") shouldBe "已完成"
backupStageDisplayName("restic") shouldBe "上传至 Restic"
backupStageDisplayName("done") shouldBe "完成"
backupStageDisplayName("partial") shouldBe "部分完成"
}
test("empty stage falls back to 处理中 (not 完成)") {
// 回归测试:原来空字符串 per-app "done" 会让 UI 反复闪"完成"
// 现在空串显示"处理中"per-app 完成是"已完成",避免误导用户。
backupStageDisplayName("") shouldBe "处理中"
}
test("unknown stage is returned as-is") {
backupStageDisplayName("weird-stage") shouldBe "weird-stage"
}
test("every stage produced by BackupOperation has a non-default mapping") {
// 这些是 BackupOperation.kt 实际 emit 的所有 stage 值,
// 任一新增未映射会导致 UI 显示原始英文 stage需要在映射表里同步。
val emittedStages = listOf("apk", "data", "obb", "ssaid", "appdone")
emittedStages.forEach { stage ->
val label = backupStageDisplayName(stage)
label shouldNotBe stage
label.isNotEmpty() shouldBe true
}
}
}
context("restoreStageDisplayName") {
test("maps known restore stages to Chinese labels") {
restoreStageDisplayName("install") shouldBe "安装 APK"
restoreStageDisplayName("data") shouldBe "恢复数据"
restoreStageDisplayName("obb") shouldBe "恢复 OBB"
restoreStageDisplayName("ssaid") shouldBe "恢复 SSAID"
restoreStageDisplayName("permissions") shouldBe "恢复权限"
restoreStageDisplayName("appdone") shouldBe "已完成"
restoreStageDisplayName("done") shouldBe "完成"
restoreStageDisplayName("partial") shouldBe "部分完成"
}
test("empty stage falls back to 处理中") {
restoreStageDisplayName("") shouldBe "处理中"
}
test("every stage produced by RestoreOperation has a non-default mapping") {
val emittedStages = listOf("install", "data", "obb", "ssaid", "permissions", "appdone")
emittedStages.forEach { stage ->
val label = restoreStageDisplayName(stage)
label shouldNotBe stage
label.isNotEmpty() shouldBe true
}
}
}
context("partial stage is distinct from done") {
// 备份工具关键诉求:失败状态必须可被 UI 区分(染 error 色 / 不拉满进度条)。
// 这两个映射必须不同,否则 ProgressBlock 的 isError 分支永不触发。
test("backup partial != done") {
backupStageDisplayName("partial") shouldNotBe backupStageDisplayName("done")
}
test("restore partial != done") {
restoreStageDisplayName("partial") shouldNotBe restoreStageDisplayName("done")
}
}
context("property: never returns null and always non-blank for any string") {
test("arbitrary strings yield non-blank labels") {
val knownStages = Arb.element("apk", "data", "", "weird", "done", "partial")
checkAll(50, knownStages) { stage ->
backupStageDisplayName(stage).isNotEmpty() shouldBe true
restoreStageDisplayName(stage).isNotEmpty() shouldBe true
}
}
test("property: any random non-empty stage string is returned non-blank") {
checkAll(50, Arb.string(minSize = 1, maxSize = 20)) { s ->
restoreStageDisplayName(s).isNotEmpty() shouldBe true
}
}
}
})