W65: 分享确认面板的预览改走与气泡同一套解析(所见即所发)

W61 审查发现的那处不一致:预览把正文 trim 之后**原样显示**、不过解析器,而发送后的气泡走
`domain/parse` 的 `parse`——分享 `"<p><p>正文</p>"` 进来,预览里是一串标签、气泡里只剩
「正文」。用户按发送之前看到的不是他将要发出的东西。

- 预览正文区改为渲染 `sharePreviewBlocks(text)`:就是 `MessageBubble` 用的那一个 `parse`
  的结果(多段 ⇒ 多个块;`<action>` / `<narration>` 与星号那几种写法 ⇒ 灰斜体小字的旁白行)。
  面板不再自己写一套「剥标签」;`parse` 一行未改。解析结果为空时按「整段原样显示」兜底,
  与 `MessageBubble` 的 `parsed.ifEmpty { plainBubble(content) }` 同一条——绝不显示空白。
- 「超窗」判据从「数一个 Text 的行数」换成**比高度**:内容自然高度(`onSizeChanged` 挂在
  `verticalScroll` **里面**,量到的是不受上限影响的自然高)vs 窗口高度(量到的行高 ×
  SHARE_PREVIEW_TEXT_LINES)。比较在组合期算完、由 `SideEffect` 报上去(排版回调里读到的两个量
  都是上一帧的,在那里比会永远差一帧)。窗口高也不再取「第三行的底边」:多块之后没有任何一个块的
  第三行等于这一窗的第三行。
- 两处 `verticalScroll` 的有限约束一如既往:外层预览区仍是 `heightIn(sharePreviewMaxHeight)`;
  正文窗仍是 `heightIn(shareConfirmWindowCap(cap))`(唯一出口,返回值恒有限),展开态把上限与滚动
  一起去掉。W58 那两次崩溃的形状一个都没碰。
- 测试:`ShareTextPipelineTest` 里那条钉着「预览原样、气泡解析」分工的用例按新口径**推翻重写**
  (预览与气泡的正文文本一致、标签不进可见文本),另加三条(多段块数 / 旁白段 / 空解析兜底);
  `ShareConfirmTest` 的超窗判据用例换成新纯函数的穷举,并新增 `sharePreviewWindowHeight` 的用例。

验证:`assembleDebug` 通过;`testDebugUnitTest` 121 suites / 1476 tests / 0 failures / 0 errors。
This commit is contained in:
AskaEth committed 2026-10-07 11:38:07 +08:00
1 parent 541a70f126
commit cdd58fa9c4
3 files changed
+437 -79

No files matched your search

@@ -44,12 +44,26 @@ import top.yeij.cyrene.domain.share.SharedDocument
* `the preview window stays three lines tall`。
*
* ## W56 又加上了什么(主人拍板:「可以滚动 可以展开」,两个都要)
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;所以正文排下来超过三行时([sharePreviewTextOverflowsWindow])
* 卡片右上一枚「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;所以这一窗**装不下**里面的内容时
* ([sharePreviewTextOverflowsWindow])卡片右上一枚「展开」把这一窗放到
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。
* **展开与滚动本身仍然是渲染层的事、单测够不着**,所以这里钉的同样是它们的两个前提:**什么时候该
* 出现那枚控件**、**展开态给多高**——两条判据都做成了纯函数([sharePreviewTextOverflowsWindow] /
* [sharePreviewMaxHeight]),见 `the expand control shows up only when the body does not fit the
* window` 与 `expanding gives the preview more room, still within a bound`。
* 出现那枚控件**、**展开态给多高**——判据都做成了纯函数([sharePreviewTextOverflowsWindow] /
* [sharePreviewWindowHeight] / [sharePreviewMaxHeight]),见
* `the expand control shows up only when the content does not fit the window`、
* `the window is as tall as three measured lines, whatever the font scale says` 与
* `expanding gives the preview more room, still within a bound`。
*
* ## W65 把「超没超窗」的判据整个换掉了(读这一节才知道为什么是高度)
* W65 之前预览的正文是**一个 `Text`**(原样那一段字),所以「装不装得下」数得出来:`lineCount > 3`。
* W65 起预览也走 `domain/parse` 的解析([sharePreviewBlocks],与气泡同一个 `parse`),这一窗里成了
* **多个块**,每块还各自带旁白行与块间距——行数再也不覆盖窗口的量。判据因此从「数行数」换成
* **比高度**:内容自然高度 vs 窗口高度([sharePreviewTextOverflowsWindow] 的两个参数);窗口高本身也
* 不再取「第三行的底边」,而是「量到的一行 × [SHARE_PREVIEW_TEXT_LINES]」
* ([sharePreviewWindowHeight])——多块之后没有任何一个块的第三行等于这一窗的第三行。
*
* ⚠️ 解析口径本身的变化(预览与气泡由此同源)不在本文件里钉,在 `ShareTextPipelineTest` 里
* (那一份测的正是「从分享进来一路到气泡」这条跨层串)。
*/
class ShareConfirmTest {
@@ -260,29 +274,56 @@ class ShareConfirmTest {
}
// ——— W56:「展开 / 收起」(主人拍板:滚动与展开**两个都要**)———
// 展开与滚动本身在单测里断言不了(本模块没有 Compose UI 测试的依赖),能钉住的是它们的两个前提:
// 那枚控件什么时候出现、展开态给多高。两条判据都在 ShareConfirmPanel.kt 里做成了纯函数。
// 展开与滚动本身在单测里断言不了(本模块没有 Compose UI 测试的依赖),能钉住的是它们的前提:
// 那枚控件什么时候出现、这一窗有多高、展开态给多高。三条判据都在 ShareConfirmPanel.kt 里做成了
// 纯函数(W65 把「什么时候出现」从数行数换成了比高度,理由见那边的 KDoc)。
@Test
fun `the expand control shows up only when the body does not fit the window`() {
// 正好三行 = 整段都在窗里(这一窗的底就是第三行的底边):此时给一枚「展开」就是主人说的
// 「点了没反应的展开」。多一行就是第四行落在窗外,展开确有东西可看。
assertFalse(sharePreviewTextOverflowsWindow(0))
assertFalse(sharePreviewTextOverflowsWindow(1))
assertFalse(sharePreviewTextOverflowsWindow(SHARE_PREVIEW_TEXT_LINES))
assertTrue(sharePreviewTextOverflowsWindow(SHARE_PREVIEW_TEXT_LINES + 1))
assertTrue(sharePreviewTextOverflowsWindow(40))
fun `the expand control shows up only when the content does not fit the window`() {
// 判据的两边都是**排出来的**(W65 起):内容自然高(挂在 `verticalScroll` **里面**量到的那个,
// 不受这一窗上限影响)与窗口高(量到的行高 × 三)。这里把它的全部形状穷举完。
//
// 还没量到的那一帧(0):不给控件——首帧摆上一枚点了没反应的「展开」,比它晚一帧出现更糟。
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 0, windowHeightPx = 0))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 0, windowHeightPx = 100))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 100, windowHeightPx = 0))
// 判据跟着那一窗的行数走(上面用的是常量),所以再用主人拍的那个数字面量钉一遍「这条线是
// 3」——两条一起才既钉住行为、又钉住那个数(同 `the preview window stays three lines tall`
// 的口径)。
assertFalse(sharePreviewTextOverflowsWindow(3))
assertTrue(sharePreviewTextOverflowsWindow(4))
// 边界是**严格大于**:一样高就是整段都在窗里(窗底一条不多),此时给一枚「展开」是主人说的
// 「点了没反应的展开」;多一个像素就是窗里还有东西没露出来,展开确有东西可看。
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 100, windowHeightPx = 100))
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 101, windowHeightPx = 100))
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 400, windowHeightPx = 60))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 59, windowHeightPx = 60))
// 接一份真分享走一遍:正文里每有一个换行,排下来就至少多一行,所以 40 行的那段一定超过窗口
// (排版后的行数只会比 `lines().size` 更多——单行太长还会折行)。
// 单调:内容只增、窗口只减,都只会把它推向「超窗」——两个方向各钉一条,防止将来有人把比较写反。
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 1000, windowHeightPx = 60))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 59, windowHeightPx = 600))
// 接一份真分享走一遍:40 行的正文在解析后仍然是一块(无标签的正文按既有语义整段成块),而这一块
// 排下来必然高过三行窗。⚠️ 「高多少」是**渲染层排出来的像素**,单测这里拿不到(本模块没有 Compose
// UI 测试依赖),所以这一条只钉到「内容就是那 40 行」为止——旧口径按行数比得出 40 > 3,新口径下
// 那个比较整个属于渲染层,这一份测试能守的就是它的输入。
val preview = shareConfirmPreview(SharedContent(text = (1..40).joinToString("\n") { "第 $it 行" }))
assertTrue(sharePreviewTextOverflowsWindow(preview.text.lines().size))
assertEquals(40, sharePreviewBlocks(preview.text).single().body.lines().size)
}
@Test
fun `the window is as tall as three measured lines, whatever the font scale says`() {
// 窗口高**没有**自己的 dp 常量:它是「量到的一行高度」× SHARE_PREVIEW_TEXT_LINES(理由见
// `ShareConfirmPanel` 里 `sharePreviewWindowHeight` 的 KDoc)。3 这个数只有一处。
assertEquals(3, SHARE_PREVIEW_TEXT_LINES)
assertEquals(60.dp, sharePreviewWindowHeight(20.dp))
assertEquals(20.dp * SHARE_PREVIEW_TEXT_LINES, sharePreviewWindowHeight(20.dp))
// 大字体档:量到的行高更大 ⇒ 窗口跟着更高。这正是「不许写死一个 dp 常量」的算术面——写死的那个
// 数在大字号档上会让这一窗矮到不足三行、末行被切。
assertEquals(120.dp, sharePreviewWindowHeight(40.dp))
assertTrue(sharePreviewWindowHeight(40.dp) > sharePreviewWindowHeight(20.dp))
// 它下一站就是 `heightIn`,所以给出去的东西必须恒有限(那一条的整体不变量见
// `ShareConfirmWindowCapTest`)。
assertTrue(sharePreviewWindowHeight(1.dp).value.isFinite())
assertTrue(shareConfirmWindowCap(sharePreviewWindowHeight(20.dp)).value.isFinite())
}
@Test
@@ -7,6 +7,8 @@ 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.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
@@ -28,9 +30,19 @@ import top.yeij.cyrene.domain.share.SharedContent
* 2. **正文里的标记按既有语义走,且绝不抛异常**:分享进来的那段字里带 `<p>` / `<action>` / 星号时,
* 渲染结果由 `domain/parse` 的既有口径决定(W55 的落单标签剥离、旁白/动作标记、Markdown 强调不吞),
* 这里只钉「不崩、且除标记本身之外一个字不少」。
* 3. **面板预览与气泡渲染是两条路**:预览走的是**原样文本**(不过解析器),气泡走解析器——
* 于是同一段带标记的文本,预览与发出后的气泡长得不一样。这不是崩溃,是本文件要**如实记下**的
* 一处不一致(见 `the preview shows the raw text while the bubble goes through the parser`)。
* 3. **面板预览与气泡走的是同一套解析**(W65 起):预览的正文区渲染的就是 `MessageBubble` 用的那
* 同一个 `parse` 的结果(`sharePreviewBlocks` 只给它加了一条兜底),所以「预览里是一串标签、
* 气泡里只剩正文」这类不一致不会再出现——见下面那几条 W65 的用例。
*
* ## W65 改掉了什么(口径变更,读这份测试时要先知道)
* W61 审查发现:预览把正文 `trim` 之后**原样显示**、不过解析器,而发送后的气泡过解析器。分享一段
* `"<p><p>正文</p>"` 进来,**预览里是一串标签、气泡里只剩「正文」**——用户按发送之前看到的不是他
* 将要发出的东西(这不是崩溃,是当时如实记下的一处不一致)。
*
* W65 把这条口径改掉了:预览按**与气泡同一套解析**渲染(多段 → 多个块,`<action>` / `<narration>`
* → 灰斜体小字,星号规则同源)。所以本文件原本如实记下的那处不一致(旧用例
* `the preview shows the raw text while the bubble goes through the parser`)**已被推翻**,替换成
* 新口径下的断言:**预览与气泡的正文文本一致**,且标签一个字都不显示。
*
* ⚠️ 本文件**不**覆盖渲染层本身(Compose):本模块没有 Compose UI 测试依赖,`Dp.Infinity` 那一类
* 崩溃单测抓不到(那是 W58 的教训)。这里管的是**进渲染层之前**那份数据对不对、会不会抛异常。
@@ -125,17 +137,150 @@ class ShareTextPipelineTest {
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)
/**
* 预览里**看得见的字**:按渲染顺序(每块:上依附的旁白 → 正文 → 下依附的旁白),空白的不算。
*
* 这个顺序就是 `ShareConfirmPanel` 里 `ShareConfirmBlock` 的排法——用它当「用户看到的是什么」,
* 好让下面几条断言能直接钉住**可见文本**,而不只是内部结构。
*/
private fun visibleLines(blocks: List<ParsedBubble>): List<String> = buildList {
blocks.forEach { block ->
addAll(block.head.map { it.text })
add(block.body)
addAll(block.tail.map { it.text })
}
}.filter { it.isNotBlank() }
@Test
fun `the preview and the bubble show the very same text`() {
// W65 口径变更(先读类上的「W65 改掉了什么」):预览的正文区**走的就是气泡那一个 `parse`**,
// 所以两边看得见的正文与旁白必须一字不差。
//
// ⚠️ 但 `preview.text` 本身仍然是**原样那一段字**(只 `trim`)——解析发生在**渲染**这一层,没有
// 搬进 `shareConfirmPreview`。这一条也一起钉住:数据层不许替用户改字。
listOf(
"<p><p>正文</p>",
"<p>第一段</p><p>第二段</p><p>第三段</p>",
"<action>看着窗外</action>",
"<p>说话</p><action>顿了顿</action>",
"第一段\n\n第二段",
"1 < 2 和 a<b",
neteaseLike,
).forEach { raw ->
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
val preview = shareConfirmPreview(share)
val sent = shareConfirmBody(share, "")
assertEquals("预览的原始正文被改字了:$raw", raw, preview.text)
// 气泡那一侧:`MessageBubble` 用的同一个 `parse`(非空结果上那一条 `filterNot { isEmpty }`
// 不删任何东西,这里照抄它当基准——预览那一侧多出来的只是同一条兜底)。
val bubbles = parse(sent).filterNot { it.isEmpty }
.ifEmpty { listOf(ParsedBubble(sent, emptyList(), emptyList(), emptyList())) }
val rendered = sharePreviewBlocks(preview.text)
assertEquals("预览与气泡的正文不一致:$raw", bubbles.map { it.body }, rendered.map { it.body })
assertEquals(
"预览与气泡的旁白不一致:$raw",
bubbles.map { b -> (b.head + b.tail).map { it.text } },
rendered.map { b -> (b.head + b.tail).map { it.text } },
)
// 标记一个字符都不许出现在**看得见的地方**:预览此前把 `<p>` / `</p>` 原样摆给用户看,
// 那正是这一批要修的东西。
visibleLines(rendered).forEach { line ->
assertFalse("标记漏进了可见文本:$line", line.contains("<p") || line.contains("</p"))
assertFalse("标记漏进了可见文本:$line", line.contains("<action") || line.contains("<narration"))
}
}
}
@Test
fun `a share full of stray paragraph tags shows the same text in the preview as in the bubble`() {
// W61 审查发现的那处不一致的**原样输入**:多一个 `<p>`、只有一个 `</p>`(服务端确实会这样产出)。
// 旧口径:预览是 `"<p><p>正文</p>"` 这一串字符;气泡里只剩「正文」。
// 新口径(W65):预览也走 `parse`,两边都是「正文」,一个标签字符都不显示。
val raw = "<p><p>正文</p>"
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
val rendered = sharePreviewBlocks(shareConfirmPreview(share).text)
assertEquals(listOf("正文"), visibleLines(rendered))
assertEquals("正文", rendered.single().body)
// 气泡那一侧(同一条消息发出去之后渲染的那一份)逐字相同。
assertEquals(parse(shareConfirmBody(share, "")).single().body, rendered.single().body)
}
@Test
fun `each paragraph of the shared text becomes its own block in the preview`() {
// 多段 ⇒ 多个块:旧口径下 `<p>甲</p><p>乙</p>` 在预览里是**一行还带着标签的原样文本**,现在与
// 气泡里一样是两块。切段不是面板新写的规则,是 `parse` 的既有语义。
val three = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = "<p>第一段</p><p>第二段</p><p>第三段</p>",
)
assertEquals(listOf("第一段", "第二段", "第三段"), sharePreviewBlocks(three.text).map { it.body })
// 无标签的正文按**空行**分段(同上):两段就是两块,而不是一段里的一行。
val plain = ShareRules.extract(action = ShareRules.ACTION_SEND, text = "第一段\n\n第二段")
assertEquals(listOf("第一段", "第二段"), sharePreviewBlocks(plain.text).map { it.body })
// 段数不是从正文里数出来的:一行里写三个完整块也是三块(按行数只会数出 1)。
assertEquals(3, sharePreviewBlocks("<p>甲</p><p>乙</p><p>丙</p>").size)
assertEquals(2, sharePreviewBlocks("甲\n\n乙").size)
// 单个换行是**句内换行**,绝不切分(既有语义):一块里两行。
assertEquals(1, sharePreviewBlocks("甲\n乙").size)
}
@Test
fun `narration in the shared text is an annotation block, not body text`() {
// `<action>` / `<narration>` 在预览里与气泡里一样**不进正文**:那一块没有正文,这一段落在 head
// 上,由 `ShareConfirmAnnotation` 渲染成灰斜体小字。标签本身一个字符都不显示。
val action = sharePreviewBlocks("<action>看着窗外</action>").single()
assertEquals("", action.body)
assertEquals(listOf("看着窗外"), action.head.map { it.text })
assertTrue(action.head.single() is ContentPart.Action)
assertTrue(action.tail.isEmpty())
val narration = sharePreviewBlocks("<narration>旁白一句</narration>").single()
assertEquals("", narration.body)
assertTrue(narration.head.single() is ContentPart.Narration)
// 星号规则同源(`parse` 的既有规则):全角短句 ⇒ 旁白那一行;半角无关键词 ⇒ 保持 Markdown
// 强调文本,仍然留在**正文**里。
val star = sharePreviewBlocks("*今天也要加油*").single()
assertEquals("", star.body)
assertEquals(listOf("今天也要加油"), star.head.map { it.text })
assertEquals("*随便*", sharePreviewBlocks("*随便*").single().body)
// 「看得见的那几行」也钉住:旁白排在正文**之外**,按渲染顺序在正文之前(与气泡里 `head` 的位置
// 一样)。这一条是「形如气泡」这件事在纯函数层唯一能自证的地方。
assertEquals(
listOf("顿了顿", "说话"),
visibleLines(sharePreviewBlocks("<action>顿了顿</action><p>说话</p>")),
)
}
@Test
fun `a shared text the parser drops entirely is still shown verbatim`() {
// 解析结果为空时**必须有兜底**(原样显示,不是一块空白):正文那一栏是用户在按发送之前唯一能
// 核对的东西。兜底与气泡那一侧同一条(`MessageBubble`:`parsed.ifEmpty { plainBubble(content) }`)
// ——都显示**原样那一段字**,哪怕它本来就是几个没配对的标签。
val raw = "<p><p>"
assertTrue("这一档必须真的解析为空,否则下面几条断言什么也证明不了", parse(raw).isEmpty())
val block = sharePreviewBlocks(raw).single()
assertEquals(raw, block.body)
assertTrue(block.head.isEmpty())
assertTrue(block.tail.isEmpty())
assertEquals(listOf(raw), visibleLines(sharePreviewBlocks(raw)))
// 走一遍真分享的路:`preview.text` 就是 `trim` 过的原串,于是解析为空、由这一条兜底把它显示出来。
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
val preview = shareConfirmPreview(share)
assertEquals(raw, preview.text)
assertEquals(listOf(raw), visibleLines(sharePreviewBlocks(preview.text)))
// 兜底不是给空正准备的:这一档的正文本身非空白(否则面板根本不显示正文那一栏)。
assertTrue(preview.hasText)
}
@Test