merge: W61 —— 分享路径审查(补 12 条跨层串测 ShareTextPipelineTest;shareConfirmWindowCap 加有限性守卫,把「上限恒有限」从调用方好习惯变成唯一出口保证)
This commit is contained in:
commit
3d8bc61912
3 files changed
+268
-5
No files matched your search
@@ -401,8 +401,9 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
|
||||
* Compose 的 `checkScrollableContainerConstraints` 会**直接抛 `IllegalStateException` 把 App 打崩**
|
||||
* (真机复现:分享进来、面板刚打开就闪退,栈是 `ScrollNode.measure` ← `SizeNode.measure` ← `DialogLayout`)。
|
||||
*
|
||||
* 所以这一层的高度上限**只经 [shareConfirmWindowCap] 一个出口**,它保证返回值要么是 `null`
|
||||
* (这一层不设上限)、要么是**有限值**,永远不是 `Dp.Infinity`:
|
||||
* 所以这一层的高度上限**只经 [shareConfirmWindowCap] 一个出口**,它给出的**恒是一个有限值**;
|
||||
* 而「这一层干脆不设上限」只发生在展开态——那时它连 `verticalScroll` 一起去掉(见下一节),
|
||||
* 于是「无界高度 + 滚动」这个组合在两种状态下都不成立:
|
||||
* - **首帧**(还没量到)用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 有限,且正好是收起态外层预览区的上限;
|
||||
* - **展开态**整层不设上限,但**外层**预览区此时换成 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],那仍是一层
|
||||
* 有限的约束在管事(不是「没有约束」)。
|
||||
@@ -491,11 +492,18 @@ private fun ShareConfirmBody(
|
||||
* 展开态**不走这里**:那一态连 `verticalScroll` 一起去掉(只留外层预览区那一层滚),没有「上限」
|
||||
* 这个维度可给 —— 见 [ShareConfirmBody] 的 KDoc。
|
||||
*
|
||||
* ⚠️ 返回值**永远不是 [Dp.Infinity]** —— `heightIn(max = Dp.Infinity)` 等于不施加约束,而
|
||||
* ⚠️ 返回值**永远是一个有限值** —— `heightIn(max = Dp.Infinity)` 等于不施加约束,而
|
||||
* `verticalScroll` 在无界高度下被测量时 Compose 会直接抛 `IllegalStateException` 把 App 打崩
|
||||
* (W58 真机崩过两次:一次是初值用了 `Dp.Infinity`,一次是展开态只去掉了上限)。这条不变量有单测钉着。
|
||||
*
|
||||
* 所以非有限的那几个取值([Dp.Infinity] 与 [Dp.Unspecified],后者是 `NaN`)**与「还没量到」同等对待**,
|
||||
* 一律回落 [SHARE_PREVIEW_MAX_HEIGHT]:这条 KDoc 写的是一条**判据**,而不该只是对调用方的请求——本函数
|
||||
* 是这一层唯一的出口,它自己要能保证「给出去的上限总是有限」。今天唯一的调用方传的是 `null` 或
|
||||
* `getLineBottom(...).toDp()`(后者恒有限),所以这一道守卫不改变任何现状;它挡的是**下一次**有人
|
||||
* 顺手把 `Dp.Infinity` 当「不设上限」传进来(那正是 W58 第一次崩溃的形状)。
|
||||
*/
|
||||
internal fun shareConfirmWindowCap(measured: Dp?): Dp = measured ?: SHARE_PREVIEW_MAX_HEIGHT
|
||||
internal fun shareConfirmWindowCap(measured: Dp?): Dp =
|
||||
measured?.takeIf { it.value.isFinite() } ?: SHARE_PREVIEW_MAX_HEIGHT
|
||||
|
||||
/** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
|
||||
internal val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -351,9 +352,13 @@ class ShareConfirmWindowCapTest {
|
||||
|
||||
@Test
|
||||
fun `no input can produce a non finite cap`() {
|
||||
for (m in listOf(null, 0.dp, 1.dp, 150.dp, 10_000.dp)) {
|
||||
// 非有限的那两个取值都在表里:`Dp.Infinity` 是 W58 第一次崩溃的形状(当时它被当「还没量到」
|
||||
// 的哨兵值),`Dp.Unspecified`(NaN)是「我忘了给它一个数」最常见的写法。两个都必须回落兜底——
|
||||
// 这一层的出口要自己保证「给出去的上限总是有限」,而不是指望调用方永远不传错。
|
||||
for (m in listOf(null, 0.dp, 1.dp, 150.dp, 10_000.dp, Dp.Infinity, Dp.Unspecified)) {
|
||||
val cap = shareConfirmWindowCap(m)
|
||||
assertTrue("measured=$m 给出了非有限的上限:$cap", cap.value.isFinite())
|
||||
assertNotEquals("measured=$m 仍把无界约束放过去了", Dp.Infinity, cap)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package top.yeij.cyrene.ui.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.domain.chat.DocumentDelivery
|
||||
import top.yeij.cyrene.domain.chat.SharedTextPlan
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
|
||||
/**
|
||||
* 「从别的 App 分享一段**文本**进来」这条路的**端到端纯函数串**(W61 审查补的回归网)。
|
||||
*
|
||||
* ## 为什么要一个跨层的串测
|
||||
* 这条路上的每一站都各自被单测钉过(`ShareExtractionTest` 管 Intent 字段怎么读、
|
||||
* `ShareConfirmTest` 管面板的预览与拼正文、`ShareNoticeTest` 管告知、`ChatContentParserTest` 管标记),
|
||||
* 但**没有任何一个用例把这几站接起来跑一遍**——而真机上的那次事故(网易云分享文本 → 闪退)
|
||||
* 恰恰发生在接缝上:正文在 `ShareRules.extract` 里被 `trim`、在 `shareConfirmPreview` 里再 `trim`
|
||||
* 一次、在 `shareConfirmBody` 里与用户描述用空行拼接、最后整段落进 `parse` 按标记切气泡。
|
||||
* 四站各自都对,合起来也未必是用户看到的那句话。
|
||||
*
|
||||
* ## 它钉住的三件事(都是**判据**,不是实现)
|
||||
* 1. **内容一字不丢**:一次「分享单曲 + 链接 + emoji + 多行」的分享,从 Intent 字段一路到气泡正文
|
||||
* 逐字节相同;用户补的那句描述同样逐字节相同。
|
||||
* 2. **正文里的标记按既有语义走,且绝不抛异常**:分享进来的那段字里带 `<p>` / `<action>` / 星号时,
|
||||
* 渲染结果由 `domain/parse` 的既有口径决定(W55 的落单标签剥离、旁白/动作标记、Markdown 强调不吞),
|
||||
* 这里只钉「不崩、且除标记本身之外一个字不少」。
|
||||
* 3. **面板预览与气泡渲染是两条路**:预览走的是**原样文本**(不过解析器),气泡走解析器——
|
||||
* 于是同一段带标记的文本,预览与发出后的气泡长得不一样。这不是崩溃,是本文件要**如实记下**的
|
||||
* 一处不一致(见 `the preview shows the raw text while the bubble goes through the parser`)。
|
||||
*
|
||||
* ⚠️ 本文件**不**覆盖渲染层本身(Compose):本模块没有 Compose UI 测试依赖,`Dp.Infinity` 那一类
|
||||
* 崩溃单测抓不到(那是 W58 的教训)。这里管的是**进渲染层之前**那份数据对不对、会不会抛异常。
|
||||
*/
|
||||
class ShareTextPipelineTest {
|
||||
|
||||
/** 网易云那类分享的典型形态:多行、书名号、链接、emoji、全角标点。 */
|
||||
private val neteaseLike =
|
||||
"分享单曲《夜曲》 - 周杰伦 🎵\n" +
|
||||
"https://music.163.com/song?id=185809\n" +
|
||||
"只想说一句:谢谢昔涟 💛"
|
||||
|
||||
/** 一次分享的正文从 Intent 字段走到「该发出去的那段字」(面板那一站的纯函数)。 */
|
||||
private fun bodyOf(raw: String, caption: String = ""): String =
|
||||
shareConfirmBody(ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw), caption)
|
||||
|
||||
@Test
|
||||
fun `a text share reaches the bubble byte for byte`() {
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = neteaseLike)
|
||||
|
||||
assertEquals(neteaseLike, share.text)
|
||||
assertTrue(share.worthDelivering)
|
||||
assertFalse(share.isEmpty)
|
||||
assertFalse(share.contentUnreadable)
|
||||
// 一次正常的文本分享没有任何要对用户说的事(有内容、没丢件、没有读不进来)。
|
||||
assertNull(shareNoticeFor(share))
|
||||
|
||||
val preview = shareConfirmPreview(share)
|
||||
assertEquals(neteaseLike, preview.text)
|
||||
assertTrue(preview.hasText)
|
||||
assertFalse(preview.isEmpty)
|
||||
assertTrue(shareConfirmCanSend(share, ""))
|
||||
|
||||
val bubbles = parse(shareConfirmBody(share, ""))
|
||||
assertEquals(1, bubbles.size)
|
||||
assertEquals(neteaseLike, bubbles.single().body)
|
||||
assertTrue(bubbles.single().head.isEmpty())
|
||||
assertTrue(bubbles.single().tail.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the caption is appended unchanged and both halves stay readable`() {
|
||||
val caption = "这条你听过吗?"
|
||||
val body = bodyOf(neteaseLike, caption)
|
||||
|
||||
assertEquals(neteaseLike + SHARE_CONFIRM_BODY_SEPARATOR + caption, body)
|
||||
// 空行是「这是两段」的表达,而既有渲染规则把空行当**气泡边界**(`blankLineFallbackSplitsBubbles`):
|
||||
// 于是这条消息在界面上是两个气泡,两段各自逐字节不变。这不是分享引入的规则——用户手打两段
|
||||
// 同样如此;这里把它钉住,是为了让「分享 + 描述」的观感有一处能说清的话。
|
||||
assertEquals(
|
||||
listOf(neteaseLike, caption),
|
||||
parse(body).map { it.body },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the caption alone is sent when the share carried no text`() {
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = " ")
|
||||
|
||||
assertFalse(share.worthDelivering)
|
||||
assertTrue(share.isEmpty)
|
||||
// 正文全空白 ⇒ 面板里没得预览;这时能发的只剩用户补的描述(按钮判据与发送路径同源)。
|
||||
assertFalse(shareConfirmPreview(share).hasText)
|
||||
assertFalse(shareConfirmCanSend(share, ""))
|
||||
assertTrue(shareConfirmCanSend(share, "只看图"))
|
||||
assertEquals("只看图", shareConfirmBody(share, "只看图"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a very long shared text is kept whole and goes out as a file`() {
|
||||
val long = "这是一段被摊平的卡片内容。".repeat(5_000)
|
||||
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = long)
|
||||
|
||||
assertEquals(long, share.text)
|
||||
assertEquals(1, parse(shareConfirmBody(share, "")).size)
|
||||
// 「分享进来的长正文落成 .txt」是既有定案(DocumentDelivery.planSharedText);本端一个字都不改它,
|
||||
// 而这条判据决定了正文那一栏会不会被清空——两个入口共用同一份判据是刻意的。
|
||||
assertEquals(SharedTextPlan.AsDocument, DocumentDelivery.planSharedText(share.text, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `markup in the shared text is handled by the parser and never crashes`() {
|
||||
// 分享进来的字里带**真标记**时的落点:剥离/解析口径是 `domain/parse` 的既有契约(W55 起),
|
||||
// 这里只断言「不抛异常 + 结果与那条契约一致」,不发明新口径。
|
||||
val malformed = parse("<p><p>正文</p>").map { it.body }
|
||||
assertEquals(listOf("正文"), malformed)
|
||||
|
||||
val loose = parse("<action>x</action>")
|
||||
assertEquals(1, loose.size)
|
||||
assertEquals("", loose.single().body)
|
||||
assertEquals(1, loose.single().head.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the preview shows the raw text while the bubble goes through the parser`() {
|
||||
// ⚠️ 两条路的分工(本文件如实记下的一处不一致):
|
||||
// - 面板预览 = `shareConfirmPreview`,**不过解析器**,用户看到的是原样的那几个字符;
|
||||
// - 发出后的气泡 = `MessageBubble` 里的 `parse`,标记按既有语义被剥离/降级成旁白。
|
||||
// 于是 `"<p><p>正文</p>"` 这样的分享:预览里是一串标签,气泡里只剩「正文」。
|
||||
// 不崩、也不丢「正文」,但预览那句「她将收到的是什么」在这里是打了折的。
|
||||
listOf("<p><p>正文</p>", "<action>看着窗外</action>", "*动作*").forEach { raw ->
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
|
||||
assertEquals(raw, shareConfirmPreview(share).text)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stars in the shared text follow the existing markup rule`() {
|
||||
// 全角短句星号 ⇒ 动作(既有语义);半角带关键词 ⇒ 动作;半角无关键词 ⇒ 保持 Markdown 强调文本。
|
||||
val action = parse("*今天也要加油*")
|
||||
assertEquals("", action.single().body)
|
||||
assertEquals("今天也要加油", action.single().head.single().text)
|
||||
|
||||
val keyed = parse("*动作: 挥手*")
|
||||
assertEquals("", keyed.single().body)
|
||||
assertEquals("挥手", keyed.single().head.single().text)
|
||||
|
||||
assertEquals("*随便*", parse("*随便*").single().body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain angle brackets in the shared text are never eaten`() {
|
||||
listOf("1 < 2 和 a<b", "看电影《心灵奇旅》 <3", "<p2>不是标签</p2>", "a < b > c").forEach { raw ->
|
||||
assertEquals(raw, bodyOf(raw))
|
||||
assertEquals(raw, parse(bodyOf(raw)).single().body)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty whitespace and hostile inputs never throw anywhere on the way`() {
|
||||
val inputs = listOf(
|
||||
"",
|
||||
" ",
|
||||
"\n",
|
||||
"\t\n ",
|
||||
"<",
|
||||
">",
|
||||
"<p",
|
||||
"<p>",
|
||||
"</p>",
|
||||
"<p><p>",
|
||||
"<p>正文</p>",
|
||||
"<action>",
|
||||
"</action>",
|
||||
"*",
|
||||
"*",
|
||||
"\u0000",
|
||||
"\u0000<p>",
|
||||
"🎵",
|
||||
"\uD83C", // 半个代理对:畸形但可能出现在任何一段别人给的文本里
|
||||
"<p>".repeat(200),
|
||||
"<a".repeat(500),
|
||||
"あ".repeat(100_000),
|
||||
)
|
||||
|
||||
inputs.forEach { raw ->
|
||||
val outcome = runCatching {
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
|
||||
shareConfirmPreview(share)
|
||||
shareConfirmCanSend(share, "描述")
|
||||
shareNoticeFor(share)
|
||||
parse(shareConfirmBody(share, "描述"))
|
||||
}
|
||||
assertTrue(
|
||||
"输入「${raw.take(24)}…」抛了:${outcome.exceptionOrNull()}",
|
||||
outcome.isSuccess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a share that carries nothing at all stays silent`() {
|
||||
// 三个字段都没有值 ⇒ 什么都不该发生、也不该说话(否则每次误触分享都弹一句)。
|
||||
val nothing = ShareRules.extract(action = ShareRules.ACTION_SEND)
|
||||
assertFalse(nothing.worthDelivering)
|
||||
assertNull(shareNoticeFor(nothing))
|
||||
assertFalse(shareConfirmCanSend(nothing, ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stream we cannot read still speaks instead of vanishing`() {
|
||||
// 同一个 Intent 里带着内容迹象却一条都读不进来(形态认不出 / scheme 不支持):
|
||||
// 内容照旧为「空」,但**必须**有话可说——这是 `contentUnreadable` 存在的全部理由。
|
||||
val unreadable = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
streamStrings = listOf("file:///etc/hosts", "https://example.com/a.png"),
|
||||
)
|
||||
|
||||
assertTrue(unreadable.isEmpty)
|
||||
assertTrue(unreadable.worthDelivering)
|
||||
assertEquals(SHARE_UNREADABLE_NOTICE, shareNoticeFor(unreadable))
|
||||
assertFalse(shareConfirmCanSend(unreadable, ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shared document reaches the panel and keeps the send button usable`() {
|
||||
val share = SharedContent(
|
||||
text = "看这份",
|
||||
documents = listOf(
|
||||
top.yeij.cyrene.domain.share.SharedDocument(
|
||||
uri = "file:///cache/share-intake/share-1-0.md",
|
||||
displayName = "笔记.md",
|
||||
sizeBytes = 2048,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val preview = shareConfirmPreview(share)
|
||||
// 预览里带的就是这份文件本身(显示名由芯片那一栏换算,算术在 DocumentChipTest 里各测各的)。
|
||||
assertEquals(listOf("笔记.md"), preview.documents.map { it.displayName })
|
||||
assertTrue(preview.hasText)
|
||||
assertTrue(shareConfirmCanSend(share, ""))
|
||||
// 只有附件、没有正文时按钮照旧可点(服务端支持纯附件回合),且告知只说丢件、不抱怨没正文。
|
||||
assertNull(shareNoticeFor(share))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user