merge: W11 — 附件芯片可点开预览 + 「用其它应用打开 / 保存到下载目录」两条出口(FileProvider)

This commit is contained in:
AskaEth committed 2026-10-04 19:22:25 +08:00
commit 60b0eacafd
9 files changed
+1625 -10

No files matched your search

@@ -0,0 +1,310 @@
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.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.DocumentRefDto
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
/**
* 附件预览的纯逻辑:截断规则、来源选择、打开用的类型、以及「没有 App 能开」那条分支。
*
* 这里锚的四件事都是**用户能看见的后果**,不是内部实现:
* 1. 截断必须**明写**(阈值边界逐条钉住:正好等于上限 / 超一字节 / 超一行);
* 2. 「用其它应用打开」的类型**只用服务端嗅探所得的 `mime`**,绝不按扩展名猜;
* 3. 没有任何 App 能开时**如实告知**并指路保存,**不静默失败**;
* 4. 来源判定只有一处([DocumentTarget.of]),服务端来源**只能用存储名**当文件名。
*/
class DocumentPreviewTest {
private fun ref(
name: String = "0123456789abcdef0123456789abcdef.md",
mime: String = "text/plain",
) = DocumentRefDto(name = name, mime = mime)
private fun filler(size: Int) = ByteArray(size) { 'a'.code.toByte() }
// —— 字节上限 ——
@Test
fun `exactly the byte limit is shown in full`() {
val slice = DocumentPreview.slice(filler(DocumentPreviewLimits.MAX_BYTES))
assertFalse(slice.truncated)
assertEquals(PreviewTruncation.None, slice.truncatedBy)
assertNull("完整显示时一个字都不说", slice.notice)
assertEquals(DocumentPreviewLimits.MAX_BYTES, slice.shownBytes)
}
@Test
fun `one byte over the limit is truncated and said so`() {
val slice = DocumentPreview.slice(filler(DocumentPreviewLimits.MAX_BYTES + 1))
assertTrue(slice.truncated)
assertEquals(PreviewTruncation.Bytes, slice.truncatedBy)
assertEquals(DocumentPreviewLimits.MAX_BYTES, slice.shownBytes)
assertEquals(DocumentPreviewLimits.MAX_BYTES + 1, slice.totalBytes)
assertTrue(slice.notice!!, slice.notice!!.contains("只显示前 256 KB"))
}
@Test
fun `truncation always points at the two exits that give the whole file`() {
val notice = DocumentPreview.slice(filler(DocumentPreviewLimits.MAX_BYTES + 1)).notice!!
assertTrue(notice, notice.contains("只显示前"))
assertTrue(notice, notice.contains("用其它应用打开"))
assertTrue(notice, notice.contains("保存到下载目录"))
}
// —— 行数上限 ——
@Test
fun `exactly the line limit is shown in full`() {
val slice = DocumentPreview.slice(lines(DocumentPreviewLimits.MAX_LINES).toByteArray())
assertFalse(slice.truncated)
assertEquals(DocumentPreviewLimits.MAX_LINES, slice.totalLines)
assertEquals(DocumentPreviewLimits.MAX_LINES, slice.shownLines)
}
@Test
fun `one line over the limit is truncated and said so`() {
val slice = DocumentPreview.slice(lines(DocumentPreviewLimits.MAX_LINES + 1).toByteArray())
assertTrue(slice.truncated)
assertEquals(PreviewTruncation.Lines, slice.truncatedBy)
assertEquals(DocumentPreviewLimits.MAX_LINES, slice.shownLines)
assertEquals(DocumentPreviewLimits.MAX_LINES + 1, slice.totalLines)
assertTrue(slice.notice!!, slice.notice!!.contains("只显示前 5,000 行"))
}
@Test
fun `when both limits are hit the byte limit is the one that cut first`() {
// 6000 行、每行 60 字节 ≈ 366 KB:字节上限先到,所以理由报字节。
val text = buildString { repeat(6000) { append("y".repeat(60)); append('\n') } }
val slice = DocumentPreview.slice(text.toByteArray())
assertTrue(slice.truncated)
assertEquals(PreviewTruncation.Bytes, slice.truncatedBy)
assertEquals(DocumentPreviewLimits.MAX_BYTES, slice.shownBytes)
}
// —— UTF-8 边界 ——
@Test
fun `the byte cut never splits a multibyte character`() {
val bytes = "喵".repeat(DocumentPreviewLimits.MAX_BYTES / 3 + 10).toByteArray()
val slice = DocumentPreview.slice(bytes)
assertTrue(slice.truncated)
assertFalse("切点不该留下替换字符 U+FFFD", slice.text.contains('\uFFFD'))
assertEquals(0, slice.text.toByteArray().size % 3)
}
@Test
fun `the safe prefix steps back over an incomplete character`() {
val twoByte = "aé".toByteArray()
assertEquals(3, DocumentPreview.utf8SafePrefixLength(twoByte, 3))
assertEquals(1, DocumentPreview.utf8SafePrefixLength(twoByte, 2))
assertEquals(1, DocumentPreview.utf8SafePrefixLength(twoByte, 1))
}
@Test
fun `a four byte character needs all four bytes`() {
val emoji = "😀".toByteArray()
assertEquals(0, DocumentPreview.utf8SafePrefixLength(emoji, 1))
assertEquals(0, DocumentPreview.utf8SafePrefixLength(emoji, 2))
assertEquals(0, DocumentPreview.utf8SafePrefixLength(emoji, 3))
assertEquals(4, DocumentPreview.utf8SafePrefixLength(emoji, 4))
}
// —— 行数 ——
@Test
fun `a trailing newline is not an extra line`() {
assertEquals(0, DocumentPreview.lineCount(""))
assertEquals(1, DocumentPreview.lineCount("a"))
assertEquals(1, DocumentPreview.lineCount("a\n"))
assertEquals(2, DocumentPreview.lineCount("a\nb"))
assertEquals(2, DocumentPreview.lineCount("a\nb\n"))
}
@Test
fun `the byte level line count agrees with the text one`() {
assertEquals(0, DocumentPreview.lineCount(ByteArray(0)))
assertEquals(2, DocumentPreview.lineCount("a\nb".toByteArray()))
assertEquals(2, DocumentPreview.lineCount("a\nb\n".toByteArray()))
}
// —— 分块 ——
@Test
fun `the body is chunked by lines and joining the chunks returns the text`() {
val text = lines(400)
val chunks = DocumentPreview.chunks(text, DocumentPreviewLimits.LINES_PER_CHUNK)
assertEquals(2, chunks.size)
assertEquals(text, chunks.joinToString(""))
}
@Test
fun `a partial last chunk is kept`() {
assertEquals(3, DocumentPreview.chunks(lines(401), DocumentPreviewLimits.LINES_PER_CHUNK).size)
}
@Test
fun `the whole preview becomes twenty five chunks at most`() {
// 5000 行上限 ÷ 200 行一块 = 25 块,LazyColumn 因此最多测 25 个 item。
assertEquals(
DocumentPreviewLimits.MAX_LINES / DocumentPreviewLimits.LINES_PER_CHUNK,
DocumentPreview.chunks(lines(DocumentPreviewLimits.MAX_LINES)).size,
)
}
@Test
fun `an empty body has no chunks`() {
assertEquals(emptyList<String>(), DocumentPreview.chunks(""))
}
// —— 打开用的类型:只认嗅探所得的 mime ——
@Test
fun `the open type is the sniffed mime even when the extension says otherwise`() {
// 服务端对 data.json 嗅探回 text/plain——按扩展名猜成 application/json 就是说一个
// 服务端刻意不承认的类型。
val target = DocumentTarget.of(ref(name = "0123456789abcdef0123456789abcdef.json"))
assertEquals("text/plain", target.openType())
}
@Test
fun `an unknown server type falls back to octet stream instead of guessing`() {
assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forServer(""))
assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forServer(" "))
assertEquals("text/plain", DocumentOpenTypes.forServer("text/plain"))
}
@Test
fun `a local file uses the platform declaration and falls back when there is none`() {
assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forLocal(null))
assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forLocal(""))
assertEquals("text/csv", DocumentOpenTypes.forLocal("text/csv"))
}
// —— 没有 App 能开 ——
@Test
fun `no app can open it - we say so and point at saving`() {
val plan = DocumentOpen.plan("text/plain", hasHandler = false)
assertTrue(plan is DocumentOpenPlan.NoHandler)
val message = (plan as DocumentOpenPlan.NoHandler).message
assertTrue(message, message.contains("没有应用能打开"))
assertTrue(message, message.contains("保存到下载目录"))
}
@Test
fun `a handler means handing the intent off`() {
assertEquals(DocumentOpenPlan.HandOff, DocumentOpen.plan("text/plain", hasHandler = true))
}
@Test
fun `the launch fallback says the very same sentence`() {
val plan = DocumentOpen.plan("text/plain", hasHandler = false) as DocumentOpenPlan.NoHandler
assertEquals(DocumentOpen.noHandlerMessage("text/plain"), plan.message)
}
// —— 来源选择 ——
@Test
fun `a committed reference comes from the server and is named by its storage name`() {
val target = DocumentTarget.of(ref(name = "0123456789abcdef0123456789abcdef.md"))
assertEquals(DocumentSource.Server("0123456789abcdef0123456789abcdef.md", "text/plain"), target.source)
// 原始文件名在契约里不可得,保存只能用存储名(不编一个假名字)。
assertEquals("0123456789abcdef0123456789abcdef.md", target.suggestedFileName())
}
@Test
fun `a draft attachment comes from the local uri and keeps its own name`() {
val target = DocumentTarget.of(DraftDocument(uri = "content://doc/1", displayName = "报告.md"))
assertEquals(DocumentSource.Local("content://doc/1", "报告.md"), target.source)
assertEquals("报告.md", target.suggestedFileName())
}
@Test
fun `a draft with no name falls back instead of an empty file name`() {
val target = DocumentTarget.of(DraftDocument(uri = "content://doc/1", displayName = ""))
assertEquals("document.txt", target.suggestedFileName())
}
// —— 失败文案:绝不渲染成「空文件」——
private fun server(code: String, message: String, status: Int) =
CyreneException(CyreneError.Server(code, message, status))
@Test
fun `a missing server document says the server no longer has it`() {
val message = documentLoadFailureMessage(server("document_not_found", "document not found", 404))
assertEquals("服务端上已经没有这份文件了", message)
}
@Test
fun `an expired session asks for a re-login rather than another file`() {
val message = documentLoadFailureMessage(CyreneException(CyreneError.Unauthorized("unauthorized", "x")))
assertTrue(message, message.contains("重新登录"))
}
@Test
fun `the three local failures each name the action the user can take`() {
assertEquals("读不到这份文件,请重新选择", documentLoadFailureMessage(LocalDocumentUnavailableException()))
assertTrue(documentLoadFailureMessage(LocalDocumentTooLargeException()).contains("20 MB"))
assertTrue(documentLoadFailureMessage(DocumentCacheUnavailableException()).contains("缓存"))
}
@Test
fun `a network failure keeps the network reason and names the step`() {
val message = documentLoadFailureMessage(
CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
)
assertEquals("附件加载失败,网络不可用,请检查连接", message)
}
@Test
fun `no failure ever reads like an empty file`() {
val messages = listOf(
documentLoadFailureMessage(server("document_not_found", "document not found", 404)),
documentLoadFailureMessage(server("weird_code", "boom", 500)),
documentLoadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))),
documentLoadFailureMessage(LocalDocumentUnavailableException()),
documentLoadFailureMessage(LocalDocumentTooLargeException()),
documentLoadFailureMessage(DocumentCacheUnavailableException()),
documentLoadFailureMessage(IllegalStateException()),
)
assertTrue(messages.toString(), messages.all { it.isNotBlank() })
assertTrue(messages.toString(), messages.none { it.contains("空文件") || it.contains("是空的") })
}
/** 恰好 [count] 行(末行不带换行符)。 */
private fun lines(count: Int): String = buildString {
repeat(count - 1) { append("x\n") }
append('x')
}
}