merge: W9 — 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流)

This commit is contained in:
AskaEth committed 2026-10-04 18:48:42 +08:00
commit ab76798bff
14 files changed
+1668 -65

No files matched your search

@@ -0,0 +1,206 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
/**
* 待发附件的准入规则与读取边界(纯函数)。
*
* 这里锚的是**「不许静默」**这条行为承诺:被挡下的东西必须有计数、有名字、有一句能被界面讲出
* 来的话;**绝不**「只发前 5 个」或者悄悄少挂一份——服务端对 >5 回 `400 too_many_documents`、
* 对超限回 `413 document_too_large`,两边都不做静默裁剪,客户端也就不该在本地悄悄裁。
*/
class DocumentAttachmentsTest {
private fun draft(uri: String, name: String = "note.txt", size: Long = 16L) =
DraftDocument(uri = uri, displayName = name, sizeBytes = size)
private fun drafts(count: Int, prefix: String = "content://doc/") =
List(count) { draft(uri = "$prefix$it", name = "note-$it.txt") }
// —— 份数上限 ——
@Test
fun `exactly the maximum number of attachments is accepted`() {
val result = DocumentAttachments.admit(emptyList(), drafts(DocumentAttachments.MAX_PER_TURN))
assertEquals(DocumentAttachments.MAX_PER_TURN, result.accepted.size)
assertEquals(DocumentAttachments.MAX_PER_TURN, DocumentDelivery.MAX_ATTACHMENTS_PER_TURN)
assertEquals(0, result.overLimit)
assertNull(result.notice)
}
@Test
fun `one attachment over the maximum is reported, never trimmed silently`() {
val result = DocumentAttachments.admit(emptyList(), drafts(6))
assertEquals(5, result.accepted.size)
assertEquals(1, result.overLimit)
assertTrue(result.notice!!, result.notice!!.contains("最多 5 个"))
}
@Test
fun `the room left is computed against what is already attached`() {
val existing = drafts(3, prefix = "content://old/")
val result = DocumentAttachments.admit(existing, drafts(4, prefix = "content://new/"))
assertEquals(2, result.accepted.size)
assertEquals(2, result.overLimit)
}
@Test
fun `a full attachment list accepts nothing and says so`() {
val existing = drafts(DocumentAttachments.MAX_PER_TURN, prefix = "content://old/")
val result = DocumentAttachments.admit(existing, drafts(1, prefix = "content://new/"))
assertTrue(result.accepted.isEmpty())
assertEquals(1, result.overLimit)
}
// —— 单份体积 ——
@Test
fun `a file exactly at the size limit is accepted`() {
val atLimit = draft("content://doc/big", name = "big.txt", size = DocumentAttachments.MAX_FILE_BYTES)
val result = DocumentAttachments.admit(emptyList(), listOf(atLimit))
assertEquals(1, result.accepted.size)
assertNull(result.notice)
}
@Test
fun `one byte over the size limit is refused before anything is uploaded`() {
// 本地预检的意义就在这儿:30 MiB 的文件照传一遍再吃 413,用户白等的是上传那几分钟。
val overLimit = draft("content://doc/big", name = "big.txt", size = DocumentAttachments.MAX_FILE_BYTES + 1)
val result = DocumentAttachments.admit(emptyList(), listOf(overLimit))
assertTrue(result.accepted.isEmpty())
assertEquals(listOf("big.txt"), result.tooLarge)
assertTrue(result.notice!!, result.notice!!.contains("big.txt"))
}
@Test
fun `an unknown size is not judged locally`() {
// 拿不到 SIZE 时本端不猜:宁可让服务端用它自己的上限回 413(带上真实数字),
// 也不要在这边凭空拒掉一个可能合规的文件。
val unknown = draft("content://doc/x", name = "x.txt", size = DraftDocument.UNKNOWN_SIZE)
val result = DocumentAttachments.admit(emptyList(), listOf(unknown))
assertEquals(1, result.accepted.size)
assertNull(result.notice)
}
@Test
fun `an oversized file does not eat the room of the ones after it`() {
val files = listOf(
draft("content://doc/huge", name = "huge.txt", size = DocumentAttachments.MAX_FILE_BYTES + 1),
draft("content://doc/ok", name = "ok.txt"),
)
val result = DocumentAttachments.admit(emptyList(), files)
assertEquals(listOf("ok.txt"), result.accepted.map { it.displayName })
assertEquals(1, result.tooLarge.size)
}
// —— 去重 ——
@Test
fun `picking the same file twice does not attach it twice`() {
val once = draft("content://doc/a")
val result = DocumentAttachments.admit(listOf(once), listOf(once))
assertTrue(result.accepted.isEmpty())
assertEquals(1, result.duplicate)
// 重复既不算「超限」也不算「超体积」——它压根不是一个新附件。
assertEquals(0, result.overLimit)
assertTrue(result.tooLarge.isEmpty())
assertTrue(result.notice!!, result.notice!!.contains("重复"))
}
@Test
fun `duplicates inside one pick are collapsed too`() {
val result = DocumentAttachments.admit(emptyList(), listOf(draft("content://doc/a"), draft("content://doc/a")))
assertEquals(1, result.accepted.size)
assertEquals(1, result.duplicate)
}
// —— 移除 ——
@Test
fun `removeAt drops exactly one chip and survives an out of range index`() {
val files = drafts(3)
assertEquals(listOf("note-0.txt", "note-2.txt"), DocumentAttachments.removeAt(files, 1).map { it.displayName })
assertEquals(files, DocumentAttachments.removeAt(files, -1))
assertEquals(files, DocumentAttachments.removeAt(files, 3))
}
// —— URI 兜底名 ——
@Test
fun `the fallback name is the last path segment and never guesses an extension`() {
assertEquals("report.md", DocumentAttachments.fallbackDocumentName("content://doc/a/b/report.md"))
assertEquals("report.md", DocumentAttachments.fallbackDocumentName("file:///cache/x/report.md?token=1"))
// 拿不到名字就留空串:让仓库层去兜 document.txt,比本端编一个扩展名安全。
assertEquals("", DocumentAttachments.fallbackDocumentName("content://doc/"))
}
// —— 有界读取 ——
@Test
fun `readBounded returns the whole content when it fits`() {
val bytes = ByteArray(8) { it.toByte() }
assertArrayEquals(bytes, readBounded(ByteArrayInputStream(bytes), limit = 8))
assertArrayEquals(bytes, readBounded(ByteArrayInputStream(bytes), limit = 1024))
}
@Test
fun `readBounded returns null as soon as the content is over the limit`() {
// 选完之后、发送之前文件被换成一个超大文件:绝不能先在内存里把它整个读进来再报错。
val bytes = ByteArray(9)
assertNull(readBounded(ByteArrayInputStream(bytes), limit = 8))
}
@Test
fun `readBounded lets IO failures through so they can be told apart from too large`() {
// 「太大」与「读不到」要用户做的事不一样(换文件 / 重新选),所以这里**不吞**异常:
// 调用方靠「null = 超限、抛出 = 读不到」把两者分开。
val failing = object : InputStream() {
override fun read(): Int = throw IOException("boom")
}
var thrown: IOException? = null
try {
readBounded(failing, limit = 8)
} catch (e: IOException) {
thrown = e
}
assertEquals("boom", thrown?.message)
}
@Test
fun `a zero byte stream reads as empty rather than failing`() {
val empty = readBounded(ByteArrayInputStream(ByteArray(0)), limit = 8)
assertFalse(empty == null)
assertEquals(0, empty!!.size)
}
}
@@ -0,0 +1,170 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.DocumentRefDto
/**
* 附件芯片的展示换算(纯函数)。
*
* 这里锚三件事:
* 1. **状态与截断不许互相顶替**——`unreadable` 才是「读不到」,`truncated` 只说明注入被预算
* 截断(正文照样进了上下文),两句文案必须分开;
* 2. **未知 `status` 原样显示**(开放集合:不丢弃、不报错、不占位符);
* 3. 服务端**没报**的字段(`chars` 缺席、体积为 0/未知)不要编出一个数字来。
*/
class DocumentChipTest {
private fun ref(
name: String = "0123456789abcdef0123456789abcdef.md",
mime: String = "text/plain",
size: Long = 12_345,
chars: Int? = 8_000,
truncated: Boolean = false,
status: String = DocumentStatus.EXTRACTED,
) = DocumentRefDto(
name = name,
mime = mime,
size = size,
chars = chars,
truncated = truncated,
status = status,
)
// —— 类型 ——
@Test
fun `the type label comes from the extension, not from the sniffed mime`() {
// 服务端的 mime 是**嗅探基类型**(`data.json` 变 `text/plain`),按它显示类型会全变成
// 「TEXT/PLAIN」;有语义的只有存储名里的扩展名。
val chip = DocumentChips.of(ref(name = "0123456789abcdef0123456789abcdef.json"))
assertEquals("JSON", chip.typeLabel)
}
@Test
fun `a name without a usable extension is shown as a plain file`() {
assertEquals("文件", DocumentChips.typeLabel("0123456789abcdef0123456789abcdef"))
assertEquals("文件", DocumentChips.typeLabel(""))
assertEquals("文件", DocumentChips.typeLabel("a.this-is-not-an-extension"))
assertEquals("MD", DocumentChips.typeLabel("a.md"))
}
// —— 体积 / 字数 ——
@Test
fun `sizes are rendered in the unit a human reads`() {
assertEquals("0 B", DocumentChips.sizeLabel(0))
assertEquals("512 B", DocumentChips.sizeLabel(512))
assertEquals("1.0 KB", DocumentChips.sizeLabel(1024))
assertEquals("12.1 KB", DocumentChips.sizeLabel(12_345))
assertEquals("20.0 MB", DocumentChips.sizeLabel(DocumentAttachments.MAX_FILE_BYTES))
}
@Test
fun `an unknown size renders as nothing rather than a fake zero`() {
assertEquals("", DocumentChips.sizeLabel(DraftDocument.UNKNOWN_SIZE))
}
@Test
fun `the char count is grouped and a missing one stays missing`() {
assertEquals("8,000 字", DocumentChips.charsLabel(8_000))
assertEquals("0 字", DocumentChips.charsLabel(0))
assertNull(DocumentChips.charsLabel(null))
}
// —— 状态 ——
@Test
fun `the three known statuses each get their own sentence`() {
assertEquals("已读入", DocumentChips.statusLabel(DocumentStatus.EXTRACTED))
assertEquals("只看得到清单", DocumentChips.statusLabel(DocumentStatus.LISTED))
assertEquals("读不到", DocumentChips.statusLabel(DocumentStatus.UNREADABLE))
}
@Test
fun `an unknown status is shown verbatim`() {
assertEquals("extracting", DocumentChips.statusLabel("extracting"))
assertNull(DocumentChips.statusLabel(""))
}
// —— 不可读与截断是两件事 ——
@Test
fun `unreadable marks the chip grey and says so`() {
val chip = DocumentChips.of(ref(status = DocumentStatus.UNREADABLE, chars = 0))
assertTrue(chip.unreadable)
assertEquals("读不到", chip.statusLabel)
assertNull("不可读不该被说成截断", chip.truncatedLabel)
}
@Test
fun `truncated only means the injection was cut and never means unreadable`() {
val chip = DocumentChips.of(ref(truncated = true, status = DocumentStatus.EXTRACTED))
assertFalse(chip.unreadable)
assertEquals("已读入", chip.statusLabel)
assertEquals("注入已截断", chip.truncatedLabel)
assertTrue(chip.detail, chip.detail.contains("已读入"))
assertTrue(chip.detail, chip.detail.contains("注入已截断"))
}
@Test
fun `a plain reference has no status sentence at all`() {
val chip = DocumentChips.of(ref(chars = null, status = ""))
assertNull(chip.statusLabel)
assertNull(chip.truncatedLabel)
assertEquals("MD · 12.1 KB", chip.detail)
}
// —— 副行拼接 ——
@Test
fun `the detail line joins what is actually known`() {
val chip = DocumentChips.of(ref())
assertEquals("MD · 12.1 KB · 8,000 字 · 已读入", chip.detail)
}
@Test
fun `the detail line skips the parts the server did not report`() {
val chip = DocumentChips.of(ref(size = 0, chars = null, status = DocumentStatus.LISTED))
assertEquals("MD · 0 B · 只看得到清单", chip.detail)
}
// —— 待发附件(还没有任何服务端结论)——
@Test
fun `a draft attachment shows its local name and size with no status`() {
val chip = DocumentChips.of(
DraftDocument(uri = "content://doc/1", displayName = "报告.md", sizeBytes = 2048),
)
assertEquals("报告.md", chip.name)
assertEquals("MD", chip.typeLabel)
assertEquals("2.0 KB", chip.sizeLabel)
assertNull(chip.statusLabel)
assertFalse(chip.unreadable)
assertEquals("MD · 2.0 KB", chip.detail)
}
@Test
fun `a draft with no metadata falls back to the uri and says nothing about size`() {
val chip = DocumentChips.of(
DraftDocument(
uri = "content://doc/1",
displayName = "",
sizeBytes = DraftDocument.UNKNOWN_SIZE,
),
)
assertEquals("content://doc/1", chip.name)
assertEquals("", chip.sizeLabel)
}
}
@@ -236,4 +236,41 @@ class DocumentDeliveryTest {
fun `the txt file name can be overridden for the share intake`() {
assertEquals("报告.md", DocumentDelivery.textAsFile("正文", fileName = "报告.md").fileName)
}
// —— 只看份数的那条入口(调用方还没读字节时用)——
@Test
fun `planForCount answers the same shape as plan for every branch`() {
// 只比「形状」(拒绝 / 该不该走上传 / 正文要不要转文件):`plan` 还会把字节带上,
// `planForCount` 带不了——那是它存在的理由(见下一个用例)。
assertEquals(DocumentPlan.Inline, DocumentDelivery.planForCount("嗨", 0))
assertEquals(
DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = false),
DocumentDelivery.planForCount("嗨", 2),
)
assertEquals(
DocumentPlan.Rejected(DocumentRejection.TooManyAttachments),
DocumentDelivery.planForCount("", 6),
)
}
@Test
fun `planForCount only differs from plan by not carrying the files`() {
// 调用方(ChatViewModel)用它**在读字节之前**决定「要不要拒绝 / 要不要走上传这条链」——
// 字节是发送时才现读的,最多 5×20 MiB 一次性读进内存不可接受。
val two = files(2)
assertEquals(
DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = false),
DocumentDelivery.planForCount("看看这个", two.size),
)
}
@Test
fun `planForCount stops at the same attachment limit`() {
assertEquals(
DocumentPlan.Rejected(DocumentRejection.TooManyAttachments),
DocumentDelivery.planForCount("", DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1),
)
}
}
@@ -15,6 +15,15 @@ import top.yeij.cyrene.domain.chat.SendChannel
*/
class ShareExtractionTest {
/**
* 分享进来的一张图:`ContentResolver.getType` 照例回图片那一类。分流就发生在提取这一步,
* 所以每个与图片有关的用例都必须把它交进来——**默认值是「拿不到类型」**,那按文档路走。
*/
private val imageMime: (String) -> String? = { "image/png" }
/** 分享进来的一个文件:文件管理器对 `.txt` / `.md` / `.log` 的常见报法。 */
private val textMime: (String) -> String? = { "text/plain" }
// —— 文本 ——
@Test
@@ -26,6 +35,7 @@ class ShareExtractionTest {
assertEquals("帮我看一下这段话", share.text)
assertTrue(share.images.isEmpty())
assertTrue(share.documents.isEmpty())
assertEquals(0, share.droppedImages)
assertFalse(share.isEmpty)
}
@@ -77,9 +87,11 @@ class ShareExtractionTest {
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
stream = "content://media/external/images/media/7",
mimeOf = imageMime,
)
assertEquals(listOf("content://media/external/images/media/7"), share.images)
assertTrue(share.documents.isEmpty())
assertFalse(share.isEmpty)
}
@@ -89,6 +101,7 @@ class ShareExtractionTest {
action = ShareRules.ACTION_SEND,
text = "看这个",
stream = "content://media/external/images/media/7",
mimeOf = imageMime,
)
assertEquals("看这个", share.text)
@@ -99,7 +112,11 @@ class ShareExtractionTest {
fun `a multiple image share keeps them in order`() {
val uris = (1..3).map { "content://media/external/images/media/$it" }
val share = ShareRules.extract(action = ShareRules.ACTION_SEND_MULTIPLE, streams = uris)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND_MULTIPLE,
streams = uris,
mimeOf = imageMime,
)
assertEquals(uris, share.images)
assertEquals(0, share.truncatedImages)
@@ -109,7 +126,11 @@ class ShareExtractionTest {
fun `images beyond the cap are reported instead of silently dropped`() {
val uris = (1..7).map { "content://media/external/images/media/$it" }
val share = ShareRules.extract(action = ShareRules.ACTION_SEND_MULTIPLE, streams = uris)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND_MULTIPLE,
streams = uris,
mimeOf = imageMime,
)
assertEquals(ShareRules.MAX_IMAGES, share.images.size)
assertEquals(uris.take(ShareRules.MAX_IMAGES), share.images)
@@ -128,6 +149,7 @@ class ShareExtractionTest {
"content://media/external/images/media/1",
"content://media/external/images/media/2",
),
mimeOf = imageMime,
)
assertEquals(
@@ -143,7 +165,11 @@ class ShareExtractionTest {
@Test
fun `a single image share ignores a stream that only holds blank space`() {
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, stream = " ")
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
stream = " ",
mimeOf = imageMime,
)
assertTrue(share.images.isEmpty())
assertTrue(share.isEmpty)
@@ -159,6 +185,7 @@ class ShareExtractionTest {
"content://media/external/images/media/9",
"content://media/external/images/media/8",
),
mimeOf = imageMime,
)
assertEquals(
@@ -179,11 +206,102 @@ class ShareExtractionTest {
"content://media/external/images/media/1",
"content://media/external/images/media/2",
),
mimeOf = imageMime,
)
assertEquals(listOf("content://media/external/images/media/1"), share.images)
}
// —— 文件(非图片)分流 ——
@Test
fun `a non image stream goes to the document path instead of the image path`() {
// 这是本模块修掉的那个真事故:一份 `.md` 此前被当图片拷、当图片解码,
// 结局只有「图片处理失败」或者被当成一张读不出来的图。
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
stream = "content://com.android.providers.downloads.documents/document/42",
mimeOf = textMime,
)
assertTrue("非图片不该进图片路", share.images.isEmpty())
assertEquals(
listOf("content://com.android.providers.downloads.documents/document/42"),
share.documents.map { it.uri },
)
assertFalse(share.isEmpty)
}
@Test
fun `a stream whose type is unknown goes to the document path, never guessed`() {
// provider 不报类型(getType 回 null)时按文档路走:本端不猜类型,猜错就是替用户
// 把一个合规文件变成一次失败。默认 mimeOf 就是「拿不到类型」。
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
stream = "content://com.example/files/notes",
)
assertTrue(share.images.isEmpty())
assertEquals(1, share.documents.size)
}
@Test
fun `text and a file are kept side by side`() {
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = "看看这份",
stream = "content://com.example/files/report.md",
mimeOf = textMime,
)
assertEquals("看看这份", share.text)
assertEquals(1, share.documents.size)
assertEquals(0, share.truncatedDocuments)
}
@Test
fun `images and files are split before either cap is applied`() {
// 混着分享时两边各自的份数必须独立计算:图片的上限是 4、文件的上限是 5,
// 先按一个上限裁完再分流,会把另一边的份额挤掉。
val uris = (1..6).map { "content://media/external/images/media/$it" } +
(1..7).map { "content://com.example/files/doc$it" }
val mimeOf: (String) -> String? = { uri ->
if (uri.contains("images")) "image/png" else "text/plain"
}
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND_MULTIPLE,
streams = uris,
mimeOf = mimeOf,
)
assertEquals(ShareRules.MAX_IMAGES, share.images.size)
assertEquals(6 - ShareRules.MAX_IMAGES, share.truncatedImages)
assertEquals(ShareRules.MAX_DOCUMENTS, share.documents.size)
assertEquals(7 - ShareRules.MAX_DOCUMENTS, share.truncatedDocuments)
assertEquals(2, share.droppedDocuments)
}
// —— MIME 分流本身(纯函数)——
@Test
fun `the image prefix decides the path, case and parameters included`() {
assertEquals(SharedStreamKind.Image, classifySharedStream("image/png"))
assertEquals(SharedStreamKind.Image, classifySharedStream("image/jpeg; charset=binary"))
assertEquals(SharedStreamKind.Image, classifySharedStream("IMAGE/PNG"))
assertEquals(SharedStreamKind.Image, classifySharedStream(" image/webp "))
}
@Test
fun `anything that is not an image goes to the document path`() {
assertEquals(SharedStreamKind.Document, classifySharedStream(null))
assertEquals(SharedStreamKind.Document, classifySharedStream(""))
assertEquals(SharedStreamKind.Document, classifySharedStream("text/plain"))
assertEquals(SharedStreamKind.Document, classifySharedStream("application/octet-stream"))
// 已知会被服务端拒的也走文档路:由服务端按 415 说,而不是在这儿假装它是图片。
assertEquals(SharedStreamKind.Document, classifySharedStream("application/pdf"))
}
// —— 超大文本 ——
@Test
@@ -229,4 +347,30 @@ class ShareExtractionTest {
fun `an empty token still yields a usable file name`() {
assertEquals("share-x-0.png", ShareRules.cacheFileName("", 0, "image/png"))
}
// —— 文件的缓存文件名 ——
@Test
fun `a document cache name keeps the original extension so the server whitelist can judge it`() {
assertEquals("share-3f2a-0.md", ShareRules.documentCacheFileName("3f2a", 0, "报告.md"))
assertEquals("share-3f2a-2.toml", ShareRules.documentCacheFileName("3f2a", 2, "Cargo.toml"))
}
@Test
fun `a document cache name falls back to txt when there is no usable extension`() {
assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, ""))
assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, "notes"))
// 超长 / 带分隔符的「扩展名」不是扩展名:一律回落 txt,绝不把它接进路径。
assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, "a.this-is-way-too-long"))
assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, "a.md/../../etc/passwd"))
}
@Test
fun `a hostile document name cannot escape the cache directory either`() {
val name = ShareRules.documentCacheFileName("../../etc", 0, "../../etc/passwd")
assertFalse(name, name.contains('/'))
assertFalse(name, name.contains(".."))
assertTrue(name, name.startsWith("share-"))
}
}
@@ -0,0 +1,103 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
/**
* 附件上传失败的文案(纯函数)。
*
* 这里锚的是**每个拒绝码对应的正确动作**:这四个码在用户能做的事上各不相同
* (415/413 = 换文件、400 = 少选几个、404 = 重传、401/403 = 重新登录),折成一句
* 「附件失败」等于让用户去猜。文案由**服务端**给(含真实上限),客户端**不自己编数字**。
*
* 另一个锚点是「这是上传这一步失败」:网络类失败必须点明,否则用户看到「无法连接到服务器」
* 会以为整条消息没发出去,而实际上文字与附件一起回到了输入框。
*/
class DocumentUploadFailureTest {
private fun server(code: String, message: String, status: Int = 400) =
CyreneException(CyreneError.Server(code, message, status))
@Test
fun `unsupported media type keeps the server wording instead of inventing one`() {
val message = documentUploadFailureMessage(
server("unsupported_media_type", "extension not allowed or content is not plain text", 415),
)
assertEquals("extension not allowed or content is not plain text", message)
}
@Test
fun `too large keeps the server limit so the number is the real one`() {
val message = documentUploadFailureMessage(
server("document_too_large", "file exceeds the 20971520 byte limit", 413),
)
assertEquals("file exceeds the 20971520 byte limit", message)
}
@Test
fun `too many documents keeps the server wording as well`() {
val message = documentUploadFailureMessage(server("too_many_documents", "at most 5 documents", 400))
assertEquals("at most 5 documents", message)
}
@Test
fun `a missing document says to send it again`() {
val message = documentUploadFailureMessage(server("document_not_found", "document not found", 404))
assertEquals("document not found", message)
}
@Test
fun `an expired session asks for a re-login, not for another file`() {
val message = documentUploadFailureMessage(
CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")),
)
assertTrue(message, message.contains("重新登录"))
assertFalse("鉴权失败不该劝用户换文件", message.contains("换"))
}
@Test
fun `a network failure names the step that failed and keeps the reason`() {
val message = documentUploadFailureMessage(
CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
)
assertEquals("附件上传失败,网络不可用,请检查连接", message)
}
@Test
fun `an unknown server code still carries the server message`() {
val message = documentUploadFailureMessage(server("weird_new_code", "something went wrong", 500))
assertEquals("附件上传失败,something went wrong", message)
}
@Test
fun `a non Cyrene throwable still produces an actionable message`() {
assertEquals("附件上传失败,请重试", documentUploadFailureMessage(IllegalStateException("boom")))
assertEquals("附件上传失败,请重试", documentUploadFailureMessage(RuntimeException()))
}
@Test
fun `no failure path ever claims the attachment was sent`() {
val messages = listOf(
documentUploadFailureMessage(server("unsupported_media_type", "x", 415)),
documentUploadFailureMessage(server("document_too_large", "x", 413)),
documentUploadFailureMessage(server("too_many_documents", "x", 400)),
documentUploadFailureMessage(server("document_not_found", "x", 404)),
documentUploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))),
documentUploadFailureMessage(IllegalStateException()),
)
// 文案里绝不能出现「已发送 / 已上传」这类暗示:失败即整轮回滚,附件与正文一起回到输入框。
assertTrue(messages.toString(), messages.none { it.contains("已发送") || it.contains("已上传") })
}
}