merge: W11 — 附件芯片可点开预览 + 「用其它应用打开 / 保存到下载目录」两条出口(FileProvider)
This commit is contained in:
commit
60b0eacafd
9 files changed
+1625
-10
No files matched your search
@@ -0,0 +1,260 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
import top.yeij.cyrene.data.api.DocumentsApi
|
||||
import top.yeij.cyrene.data.api.dto.DocumentUploadResponse
|
||||
import top.yeij.cyrene.domain.chat.DocumentSource
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* 附件缓存的路径推导、命中判定、原子落盘,以及「先看缓存、未命中才下载」这条取字节的路。
|
||||
*
|
||||
* 用**真临时目录** + 一个记账的假 `DocumentsApi`(不是 Android 的 `Context`):缓存这一层的
|
||||
* 全部行为都是文件系统行为,只有把它从 `ContentResolver` / `Uri` 里摘出来才测得到
|
||||
* (那两个类在 JVM 单测里是 stub,一碰就抛)。
|
||||
*
|
||||
* 这里锚的三件事:
|
||||
* 1. **命中 = 永不失效**(服务端存储名是内容寻址的),第二次打开**一个请求都不发**;
|
||||
* 2. **失败绝不留半截文件**(半截文件被下一次「命中」当成完整内容,是静默的错误内容);
|
||||
* 3. 名字不安全时不拼路径(缓存目录是唯一被 FileProvider 暴露出去的根)。
|
||||
*/
|
||||
class DocumentCacheTest {
|
||||
|
||||
private lateinit var cacheRoot: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
cacheRoot = Files.createTempDirectory("cyrene-documents").toFile()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
cacheRoot.deleteRecursively()
|
||||
}
|
||||
|
||||
// —— 路径与名字 ——
|
||||
|
||||
@Test
|
||||
fun `the cache lives under cacheDir documents`() {
|
||||
assertEquals(File(cacheRoot, "documents"), DocumentCache.directory(cacheRoot))
|
||||
assertEquals(File(File(cacheRoot, "documents"), NAME), DocumentCache.fileFor(cacheRoot, NAME))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unsafe name never becomes a path`() {
|
||||
assertNull(DocumentCache.fileFor(cacheRoot, ""))
|
||||
assertNull(DocumentCache.fileFor(cacheRoot, " "))
|
||||
assertNull(DocumentCache.fileFor(cacheRoot, "."))
|
||||
assertNull(DocumentCache.fileFor(cacheRoot, ".."))
|
||||
assertNull(DocumentCache.fileFor(cacheRoot, "../escape.txt"))
|
||||
assertNull(DocumentCache.fileFor(cacheRoot, "sub/dir.txt"))
|
||||
assertNull(DocumentCache.fileFor(cacheRoot, "sub\\dir.txt"))
|
||||
assertFalse(DocumentCache.isSafeName("a\u0000b"))
|
||||
assertTrue(DocumentCache.isSafeName(NAME))
|
||||
}
|
||||
|
||||
// —— 命中判定 ——
|
||||
|
||||
@Test
|
||||
fun `a missing or empty file is not a hit`() {
|
||||
val file = DocumentCache.fileFor(cacheRoot, NAME)!!
|
||||
|
||||
assertFalse("还没落盘就不是命中", DocumentCache.isHit(file))
|
||||
file.parentFile!!.mkdirs()
|
||||
file.writeBytes(ByteArray(0))
|
||||
assertFalse("0 字节是失败落盘的残留,不能当完整内容用", DocumentCache.isHit(file))
|
||||
file.writeBytes("hello".toByteArray())
|
||||
assertTrue(DocumentCache.isHit(file))
|
||||
}
|
||||
|
||||
// —— 原子落盘 ——
|
||||
|
||||
@Test
|
||||
fun `a write leaves exactly the target and no part files`() {
|
||||
val file = DocumentCache.fileFor(cacheRoot, NAME)!!
|
||||
|
||||
DocumentCache.write(file, "hello md".toByteArray())
|
||||
|
||||
assertEquals("hello md", file.readText())
|
||||
assertEquals(listOf(NAME), file.parentFile!!.list()!!.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a write replaces whatever was there`() {
|
||||
val file = DocumentCache.fileFor(cacheRoot, NAME)!!
|
||||
|
||||
DocumentCache.write(file, "old".toByteArray())
|
||||
DocumentCache.write(file, "new".toByteArray())
|
||||
|
||||
assertEquals("new", file.readText())
|
||||
assertEquals(listOf(NAME), file.parentFile!!.list()!!.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed write leaves nothing behind`() {
|
||||
// 拿一个**普通文件**当缓存目录:建目录必然失败,因此连临时文件都生不出来。
|
||||
val blocked = File(cacheRoot, "blocked").apply { writeBytes("not a directory".toByteArray()) }
|
||||
val file = File(File(blocked, "documents"), NAME)
|
||||
|
||||
val failed = runCatching { DocumentCache.write(file, "hello".toByteArray()) }.isFailure
|
||||
|
||||
assertTrue("写不进去必须抛,不能假装成功", failed)
|
||||
assertFalse("失败的写不能留下任何东西", file.exists())
|
||||
assertFalse("半截临时文件也不许留下", File(blocked, "documents").exists())
|
||||
}
|
||||
|
||||
// —— 内容寻址的本地名 ——
|
||||
|
||||
@Test
|
||||
fun `the local name is content addressed and keeps the extension`() {
|
||||
val bytes = "hello".toByteArray()
|
||||
|
||||
val name = DocumentCache.localName(bytes, "报告.MD")
|
||||
|
||||
assertEquals(32 + 3, name.length)
|
||||
assertEquals(".md", name.takeLast(3))
|
||||
assertEquals(name, DocumentCache.localName(bytes, "报告.MD"))
|
||||
assertTrue(name != DocumentCache.localName("other".toByteArray(), "报告.MD"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local name without a usable extension still gets one storage shaped name`() {
|
||||
val name = DocumentCache.localName("hello".toByteArray(), "README")
|
||||
|
||||
assertEquals(32, name.length)
|
||||
assertTrue(DocumentCache.isSafeName(name))
|
||||
}
|
||||
|
||||
// —— 取字节:先看缓存 ——
|
||||
|
||||
@Test
|
||||
fun `a server document is downloaded once and then served from cache`() = runBlocking {
|
||||
val api = FakeDocumentsApi { "hello md".toByteArray() }
|
||||
val store = store(api)
|
||||
|
||||
val first = store.load(DocumentSource.Server(NAME, "text/plain")).getOrThrow()
|
||||
val second = store.load(DocumentSource.Server(NAME, "text/plain")).getOrThrow()
|
||||
|
||||
assertEquals("hello md", String(first.bytes))
|
||||
assertEquals("hello md", String(second.bytes))
|
||||
assertEquals("内容寻址 ⇒ 命中即不再发请求", 1, api.downloads.toLong())
|
||||
assertEquals(DocumentCache.fileFor(cacheRoot, NAME), second.file)
|
||||
assertEquals("hello md", second.file.readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed download leaves no half file for the next open to believe`() = runBlocking {
|
||||
val api = FakeDocumentsApi(failWith = 404)
|
||||
val store = store(api)
|
||||
|
||||
val result = store.load(DocumentSource.Server(NAME, "text/plain"))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
val file = DocumentCache.fileFor(cacheRoot, NAME)!!
|
||||
assertFalse(file.exists())
|
||||
assertFalse("下载失败连缓存目录都不该建", DocumentCache.directory(cacheRoot).exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero byte leftover is treated as a miss and refetched`() = runBlocking {
|
||||
val file = DocumentCache.fileFor(cacheRoot, NAME)!!
|
||||
file.parentFile!!.mkdirs()
|
||||
file.writeBytes(ByteArray(0))
|
||||
val api = FakeDocumentsApi { "fresh".toByteArray() }
|
||||
|
||||
val document = store(api).load(DocumentSource.Server(NAME, "text/plain")).getOrThrow()
|
||||
|
||||
assertEquals("fresh", String(document.bytes))
|
||||
assertEquals(1, api.downloads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed server name is refused instead of being turned into a path`() = runBlocking {
|
||||
val api = FakeDocumentsApi { "hello".toByteArray() }
|
||||
|
||||
val result = store(api).load(DocumentSource.Server("../escape.txt", "text/plain"))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(0, api.downloads)
|
||||
}
|
||||
|
||||
// —— 取字节:本地待发附件 ——
|
||||
|
||||
@Test
|
||||
fun `a local draft is written content addressed and reused`() = runBlocking {
|
||||
val bytes = "报告正文".toByteArray()
|
||||
val store = store(FakeDocumentsApi { error("本地附件不该发请求") }, local = mapOf(LOCAL_URI to bytes))
|
||||
|
||||
val first = store.load(DocumentSource.Local(LOCAL_URI, "报告.md")).getOrThrow()
|
||||
val second = store.load(DocumentSource.Local(LOCAL_URI, "报告.md")).getOrThrow()
|
||||
|
||||
assertEquals("报告正文", String(first.bytes))
|
||||
assertEquals(DocumentCache.localName(bytes, "报告.md"), first.file.name)
|
||||
assertEquals(first.file, second.file)
|
||||
assertTrue(first.file.isFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local file we cannot open is reported as unreadable`() = runBlocking {
|
||||
val store = store(FakeDocumentsApi { error("不该走到网络") }, local = emptyMap())
|
||||
|
||||
val result = store.load(DocumentSource.Local("content://missing/1", "x.md"))
|
||||
|
||||
assertTrue(result.exceptionOrNull() is LocalDocumentUnavailableException)
|
||||
}
|
||||
|
||||
private fun store(
|
||||
api: DocumentsApi,
|
||||
local: Map<String, ByteArray> = emptyMap(),
|
||||
) = DocumentCacheStore(
|
||||
repository = DocumentRepository(api),
|
||||
cacheDir = cacheRoot,
|
||||
openLocal = { uri -> local[uri]?.inputStream() },
|
||||
)
|
||||
|
||||
/** 只记账 `download`;上传不在本测试的范围。 */
|
||||
private class FakeDocumentsApi(
|
||||
private val failWith: Int? = null,
|
||||
private val bytes: () -> ByteArray = { error("这份夹具没有配响应体") },
|
||||
) : DocumentsApi {
|
||||
|
||||
var downloads: Int = 0
|
||||
private set
|
||||
|
||||
override suspend fun upload(
|
||||
file: MultipartBody.Part,
|
||||
): Response<ApiEnvelope<DocumentUploadResponse>> = error("上传不在本测试的范围")
|
||||
|
||||
override suspend fun download(name: String): Response<ResponseBody> {
|
||||
downloads++
|
||||
val code = failWith
|
||||
if (code != null) {
|
||||
return Response.error(
|
||||
code,
|
||||
"""{"error":{"code":"document_not_found","message":"document not found"}}"""
|
||||
.toResponseBody("application/json".toMediaTypeOrNull()),
|
||||
)
|
||||
}
|
||||
return Response.success(bytes().toResponseBody("text/plain".toMediaTypeOrNull()))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NAME = "0123456789abcdef0123456789abcdef.md"
|
||||
const val LOCAL_URI = "content://doc/1"
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user