merge: W9 — 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流)
This commit is contained in:
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("已上传") })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user