feat(chat): 附件芯片可点开预览,并给「用其它应用打开 / 保存到下载目录」两条出口

点一枚附件芯片此前完全没有反应。现在点它会开一个 App 内预览弹层,弹层里再给两条出口。

- FileProvider(authority = ${applicationId}.fileprovider,res/xml/file_paths.xml 只暴露
  cacheDir/documents/):把 file:// 交给别的 App 在 API 24+ 会抛 FileUriExposedException,
  必须走 content://;因此 cacheDir 下真正要交出去的那一层才被开出来。
- 新增 data/repository/DocumentCache.kt:缓存路径推导 / 命中判定 / 原子落盘是可单测的纯逻辑;
  DocumentCacheStore 把「服务端按存储名下载」与「本地待发附件」两条来源汇成同一份文件 + 字节。
  服务端存储名是内容寻址(sha256[:32]+ext)且读侧响应 immutable,所以命中即永不失效、命中时
  一个请求都不发;先写 .part 再改名、失败删干净——半截文件被下一次「命中」当成完整内容,
  是最坏的一种静默错误。
- 新增 domain/chat/DocumentPreview.kt(纯逻辑):截断(首 256 KB 或 5000 行,先到者为准;
  字节切点不切坏多字节 UTF-8)、正文分块(200 行一块)、来源判定(服务端 vs 本地,只有一处)、
  打开用的 MIME(只用 DTO 里字节嗅探所得的 mime,绝不按扩展名猜)、「没有 App 能开」的裁决与
  文案、取字节失败的分类文案。
- 新增 ui/chat/DocumentPreviewDialog.kt:Compose Dialog + Miuix 内容。正文用 LazyColumn 分块渲染
  (5000 行不塞进单个 Text),每块各自 SelectionContainer;加载态与失败态分明(失败给重试,
  绝不渲染成空文件);截断明写「只显示前 N,完整内容请用…」,不静默截断;复用 DocumentChips
  显示类型/体积/字数/状态,不另写一套标签;status 为 unreadable 不挡预览(那是模型读不到,
  文件本体一直在)。
- ui/chat/MessageBubble.kt:芯片整枚可点(点击区是那枚芯片本身,不是里面的两行文字),
  并带 contentDescription「查看附件 <name>」。
- ui/chat/ChatScreen.kt:预览状态 + 取字节 + SAF 保存 + ACTION_VIEW 打开。没有 App 能开时
  如实告知并指路保存,绝不静默失败。

契约缺口(需登记 02):服务端按内容寻址落盘,DTO 里没有原始文件名,保存到下载目录只能用
<32hex>.<ext>——弹层里已把这一点写在界面上,没有替用户编一个假名字。

测试 567 → 610(+29 DocumentPreviewTest / +14 DocumentCacheTest),0 失败 0 错误。
This commit is contained in:
AskaEth committed 2026-10-04 19:21:31 +08:00
1 parent e59ef08cf9
commit 27b6910009
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')
}
}