From cdd58fa9c4dd3198366fec7f6391d2e69f1730b8 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 11:38:07 +0800 Subject: [PATCH] =?UTF-8?q?W65:=20=E5=88=86=E4=BA=AB=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=9A=84=E9=A2=84=E8=A7=88=E6=94=B9=E8=B5=B0?= =?UTF-8?q?=E4=B8=8E=E6=B0=94=E6=B3=A1=E5=90=8C=E4=B8=80=E5=A5=97=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=EF=BC=88=E6=89=80=E8=A7=81=E5=8D=B3=E6=89=80=E5=8F=91?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W61 审查发现的那处不一致:预览把正文 trim 之后**原样显示**、不过解析器,而发送后的气泡走 `domain/parse` 的 `parse`——分享 `"

正文

"` 进来,预览里是一串标签、气泡里只剩 「正文」。用户按发送之前看到的不是他将要发出的东西。 - 预览正文区改为渲染 `sharePreviewBlocks(text)`:就是 `MessageBubble` 用的那一个 `parse` 的结果(多段 ⇒ 多个块;`` / `` 与星号那几种写法 ⇒ 灰斜体小字的旁白行)。 面板不再自己写一套「剥标签」;`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。 --- .../yeij/cyrene/ui/chat/ShareConfirmPanel.kt | 258 +++++++++++++++--- .../yeij/cyrene/ui/chat/ShareConfirmTest.kt | 87 ++++-- .../cyrene/ui/chat/ShareTextPipelineTest.kt | 171 +++++++++++- 3 files changed, 437 insertions(+), 79 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt index 9df509e..66cf415 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -23,12 +24,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import top.yeij.cyrene.domain.chat.DocumentChips import top.yeij.cyrene.domain.chat.DraftDocument +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.SharedContent import top.yeij.cyrene.domain.share.SharedDocument import top.yukonga.miuix.kmp.basic.Text @@ -253,6 +260,17 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…" * 输入框与按钮原地不动。上限按状态取([sharePreviewMaxHeight]):收起态 * [SHARE_PREVIEW_MAX_HEIGHT]、用户展开后 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT]。 * + * ## 正文走**与气泡同一套解析**(W65),所以这一窗里排的是「气泡块」而不是一段原样的字 + * 正文不是原样铺进去的:它先过 [top.yeij.cyrene.domain.parse.parse]——与 `MessageBubble` 里那条 + * 完全同一个函数、同一个口径([sharePreviewBlocks] 只是给结果加了一道兜底)。于是在这里看到的就是 + * 发出去之后气泡里的样子:每个 `

…

`(无标签时按空行分段)是**一个独立的块**, + * `` / ``(以及星号那几种写法)不当正文、而是**灰斜体小字**的旁白块,标签本身 + * 一个字符都不显示。 + * + * 这一层修掉的是 W61 审查发现的一处「所见非所发」:预览此前把正文 `trim` 之后原样显示、**不过解析 + * 器**,而发送后的气泡过解析器——分享一段 `"

正文

"` 进来,**预览里是一串标签、气泡里只剩 + * 「正文」**。用户按发送之前看到的不是他将要发出的东西。 + * * ## 正文给三行,但三行之外是「滚」不是「省略号」 * 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板 * 就成了一个阅读器。所以正文那一窗**恒为三行高**([SHARE_PREVIEW_TEXT_LINES]),面板不随正文长短 @@ -265,10 +283,10 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…" * * ## 三行读不下时,再给一次「展开」 * 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;主人要的是**也能一次多看几行** - * (W56 拍板:滚动与展开**两个都要**)。所以正文排下来超过三行时(判据是 - * [sharePreviewTextOverflowsWindow],正好三行不给)卡片右上一枚「展开」把这一窗放到 - * [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。没超过三行时这一枚**不出现**—— - * 短正文上摆一枚点了没反应的「展开」比没有更糟。 + * (W56 拍板:滚动与展开**两个都要**)。所以这一窗**装不下**里面那几块时(判据是 + * [sharePreviewTextOverflowsWindow]:量出来的内容自然高度比窗口高,正好放满不给)卡片右上一枚 + * 「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。装得下时这一枚 + * **不出现**——短正文上摆一枚点了没反应的「展开」比没有更糟。 * * 「读不到尾」这条红线在两种状态下都守:收起态窗内滚,展开态由外层这一层滚(正文那一层不再设上限, * 见 [ShareConfirmBody] 的「展开态为什么干脆不留这一层上限」)。 @@ -316,8 +334,8 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) { DocumentChipRow(chips = preview.documents.map { DocumentChips.of(it.toDraftDocument()) }) } if (preview.hasText) { - // 「超没超过三行」只有排完才知道(折行位置由字号与系统的大字体缩放决定),所以它由正文 - // 本体量出来之后回调上来,标签这一行凭它决定要不要给出展开控件。 + // 「装不装得下」只有排完才知道(折行位置由字号与系统的大字体缩放决定,块数与旁白行数更是 + // 解析出来的),所以它由正文那一窗量出来之后回调上来,标签这一行凭它决定要不要给出控件。 var bodyOverflows by remember(preview.text) { mutableStateOf(false) } Column( modifier = Modifier @@ -385,16 +403,54 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) { ) } +/** 相邻两个气泡块之间的间距(见 [ShareConfirmBody])。块与块靠这一层间距分开,不靠气泡底色。 */ +private val SHARE_PREVIEW_BLOCK_GAP = 6.dp + +/** 一个块里旁白行与正文行之间的间距(气泡里那两行也挨得很近,与 `AnnotationLine` 的 2dp 同源)。 */ +private val SHARE_PREVIEW_LINE_GAP = 2.dp + /** - * 正文那一窗:**高度恒为三行,再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]); - * 用户点了「展开」之后这一窗撤掉自己的上限,整段正文交给外层预览区那一层滚(W56)。 + * 正文那一窗:**高度恒为三行、再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]); + * 用户点了「展开」之后这一窗撤掉自己的上限,全部内容交给外层预览区那一层滚(W56)。 + * + * 里面排的**不是一段原样的字**,而是 [sharePreviewBlocks] 给出的那几个「气泡块」(W65 起走与 + * `MessageBubble` 同一个 [parse])。每个块自己排旁白行与正文行,块与块之间留 + * [SHARE_PREVIEW_BLOCK_GAP]。 * * ## 为什么量行高,而不是写一个 dp 常量 * 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」 * 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号, * `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。 * 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。 - * 所以这里改为**量**:从 `TextLayoutResult` 上取第三行的底边当上限。 + * 所以这里改为**量**:任一排版结果都能给出「一行多高」(`getLineBottom(0) - getLineTop(0)`), + * 窗口高度 = 它 × [SHARE_PREVIEW_TEXT_LINES]([sharePreviewWindowHeight])。 + * + * **为什么不再取「第三行的底边」**(那是 W65 之前的量法):那时这一窗里只有一个 `Text`, + * `getLineBottom(2)` 就是那一个文本的三行底。现在里面是**多个块**,每块还有自己的旁白行与间距—— + * 在任何一个块上取「第三行」都不再是这个窗口的第三行。行高则是唯一一个跨块也成立、且与块数无关的 + * 量:窗口恒为「三行字高」,块与旁白占掉多少就从这三行里吃掉多少(这正是它的语义)。 + * + * ## 「装不下」是**量**出来的,不是**数**出来的(W65 换掉的判据) + * W65 之前这里数的是**一个 `Text` 的行数**(`lineCount > 3`)。多块之后行数不再是一个数得出来的量: + * 旁白行与块间距都占高度,却不属于任何一块的「行数」;字号缩放与折行位置也全落在行数之外。按行数算, + * 两块各三行加起来是 6、窗口只装得下 3 行——这条能对,但「一块三行 + 一条旁白 + 一层间距」这种更常见 + * 的形状就数不准了,而这恰是解析之后最常见的形状。 + * + * 所以现在比的是**高度**:内容排下来的自然高度 vs 这一窗的高度([sharePreviewTextOverflowsWindow])。 + * 两个量都是排完才知道的: + * - 窗口高由上面的行高乘三给出; + * - 自然高由 [androidx.compose.ui.layout.onSizeChanged] 读到——它挂在 `verticalScroll` **里面**, + * 而 `verticalScroll` 给子项的是无界高度,所以读到的是内容的**自然高度**(不受这一窗上限影响的那 + * 一个,收起态与展开态读到的是同一个数)。 + * + * 「自然高度 > 窗口高度」就是「窗里还有东西没露出来」,正是那枚「展开」承诺的事;旁白、块间距、折行、 + * 系统大字体缩放全被算在同一把尺子上——它可自证的地方在于:两边都是这一帧真实排出来的像素,没有一处 + * 是我们估的行数或猜的 dp。 + * + * ⚠️ 这个比较在**组合期**算完、由 [SideEffect] 报上去,**不**在排版回调里算:两个量都来自排版回调, + * 而在回调里读到的是**上一帧**组合里的值(`contentHeight` 与 `cap` 都不在本帧的组合里),在那里直接比 + * 会永远差一帧——首帧报一次「不超窗」之后就再也不会报,长正文上那枚「展开」永远不出现。组合期读到的 + * 一定是最新的一帧,代价只是一次幂等的重复上报([SideEffect] 每次组合都跑,值没变就是空写)。 * * ## ⚠️ 这一层绝不能拿到 [Dp.Infinity](W58 修的崩溃) * `heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll` 一旦在**无界高度**下被测量, @@ -408,9 +464,9 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) { * - **展开态**整层不设上限,但**外层**预览区此时换成 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],那仍是一层 * 有限的约束在管事(不是「没有约束」)。 * - * 上限只写一次(`cap` 还是 `null` 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度 - * 与样式都没变,行位置与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的 - * 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,两种状态下排出来的行位置一样)。 + * 上限只写一次(`cap` 还是 `null` 时才改),所以不会来回跳:拿到上限后重新排版,块的内容、宽度 + * 与样式都没变,行高与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的 + * 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,两种状态下排出来的行高一样)。 * * ## 展开态为什么干脆不留这一层上限 * 展开之后正文可能几屏长。若在这一窗上再设一个上限,就成了「窗外滚 + 窗内滚」两层都要滚的手势 @@ -421,13 +477,17 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) { * * ## 首帧的那次回流:留着,且确认它看不见 * 上限是**首帧之后**才量到的,所以长正文会先按 fallback([SHARE_PREVIEW_MAX_HEIGHT])排一次, - * 下一帧才收到三行。真机上**看不见**跳动,两条理由: + * 下一帧才收到三行。真机上**看不见**跳动,三条理由: * 1. 首帧那一下撑不动任何别的东西——正文块是预览区里的**最后一个**元素(图片、文件都在它上面), * 而预览区自己还有一层独立的上限在管可视高度:下面没有东西被它顶下去,上面那两样的位置一分 * 不动,可视的像素与量完之后**一样**(那些像素由 `heightIn` 的裁剪决定,裁剪高度不变)。 * 2. 展开态本来就没有这一层上限,不存在「量完再回落」;而展开 / 收起是用户主动的一次布局变化, * 那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的是展开控件本身——它要到第二帧才出现 - * (行数是排完才知道的),但这一枚也顶不动东西:标签行里那两句话用的是同一个 `footnote2`,行高不变。 + * (自然高度是排完才知道的),但这一枚也顶不动东西:标签行里那两句话用的是同一个 `footnote2`, + * 行高不变。 + * 3. 首帧那一次比出来的是「自然高度 vs 150dp 兜底」而不是「vs 三行」:那一帧的结论**可能是错的** + * (一段四五行的正文在 150dp 下看着「装得下」),但它在下一帧就被纠正,而这一枚控件本来就要到 + * 第二帧才可能摆上——所以这条错判永远看不见。 * * 另一条路是在组合期用 `rememberTextMeasurer` 把上限量出来,好让控件从首帧就摆上:那要先知道宽度 * (得外包一层 `BoxWithConstraints`),还要自己复现字号、字体缩放与字体回退,等于把排版做两遍—— @@ -451,8 +511,11 @@ private fun ShareConfirmBody( onOverflowChange: (Boolean) -> Unit, ) { val density = LocalDensity.current + val blocks = remember(text) { sharePreviewBlocks(text) } // `null` = 还没量到。**不要**拿 [Dp.Infinity] 当这个哨兵值(见 KDoc 里那次崩溃)。 var cap by remember(text) { mutableStateOf(null) } + // 内容排下来的**自然高度**(见 KDoc:量在 `verticalScroll` 里面,拿到的是不受上限影响的那一个)。 + var contentHeight by remember(text) { mutableStateOf(0) } // 无条件 `remember`,避免在分支里建状态;滚动位置由这一窗自己的生命周期管。 val scroll = rememberScrollState() @@ -465,22 +528,83 @@ private fun ShareConfirmBody( .verticalScroll(scroll) } - Column(modifier = Modifier.fillMaxWidth().then(window)) { - Text( - text = text, - style = MiuixTheme.textStyles.footnote1, - onTextLayout = { layout -> - if (cap == null && layout.lineCount > 0) { - // 正文不到三行时上限就取全文的高度(`heightIn` 是个上限,短了本来也不生效)。 - val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1 - cap = with(density) { layout.getLineBottom(lastVisible).toDp() } - } - // 行数与上限无关(这一窗没有 `maxLines`、收起态的高度不受限,排一次行数就定了),所以 - // 这一句同时管着「收不起时要不要给出展开控件」与「展开态下要不要留着它」。 - onOverflowChange(sharePreviewTextOverflowsWindow(layout.lineCount)) - }, - ) + // 一行有多高:正文行与旁白行同为 `footnote1`,所以第一个排完的文本就能给(不限是哪一块的哪一行)。 + // 量到一次就定死——`cap` 只在这一个分支里被写、且只从 `null` 变成一个值,不会来回跳。 + val measureLine: (TextLayoutResult) -> Unit = { layout -> + if (cap == null && layout.lineCount > 0) { + val line = layout.getLineBottom(0) - layout.getLineTop(0) + // 0 或负数(还没排稳)不记:150dp 的兜底比一扇零高的窗好(同 [shareConfirmWindowCap] 的取向)。 + if (line > 0) cap = sharePreviewWindowHeight(with(density) { line.toDp() }) + } } + + Column( + modifier = Modifier + .fillMaxWidth() + .then(window) + .onSizeChanged { contentHeight = it.height }, + verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_BLOCK_GAP), + ) { + blocks.forEach { block -> ShareConfirmBlock(block, measureLine) } + } + + // 见 KDoc:在组合期算完、由 SideEffect 报上去(两个量在排版回调里读到的是上一帧的值)。 + val overflows = sharePreviewTextOverflowsWindow( + contentHeightPx = contentHeight, + windowHeightPx = with(density) { shareConfirmWindowCap(cap).roundToPx() }, + ) + SideEffect { onOverflowChange(overflows) } +} + +/** + * 预览里的**一个气泡块**:上依附的旁白 + 正文 + 下依附的旁白。 + * + * 这个顺序就是 `MessageBubble` 里那个 `Bubble` 的顺序(`head` → 正文 → `tail`),用的也是同一个 + * [parse] 给出的同一批块——「预览里长这样」与「发出去之后长那样」因此是**同一套判据**的两次渲染。 + * + * 块与块之间只留一层间距(外层 [ShareConfirmBody] 的 `spacedBy`),**不画气泡底色**:这一栏是面板里 + * 的一张卡片(`surfaceContainer` 底),而气泡底色在聊天里表示「谁说的」——用户那条是 `primary` 色、 + * 她那侧是灰。在确认面板里复刻一层假气泡底色会让人以为这里已经是聊天记录;而且那层内边距要从三行窗 + * 里再吃掉近一行,正是 W54D 之后最不该动的那点可见正文。 + */ +@Composable +private fun ShareConfirmBlock(block: ParsedBubble, onTextLayout: (TextLayoutResult) -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_LINE_GAP), + ) { + ShareConfirmAnnotation(block.head, onTextLayout) + if (block.body.isNotEmpty()) { + Text( + text = block.body, + style = MiuixTheme.textStyles.footnote1, + onTextLayout = onTextLayout, + ) + } + ShareConfirmAnnotation(block.tail, onTextLayout) + } +} + +/** + * 预览里的旁白行:**灰斜体小字**——与气泡里那行(`MessageBubble` 的 `AnnotationLine`)同一个形态、 + * 同一种拼法(多个片段用一个空格连起来),字全是 [parse] 给的那几个 `ContentPart` 的原文。 + * + * ⚠️ 这里**不**读 `LocalAppPrefs.showNarration`(气泡那行会读)。那个偏好管的是**聊天记录里**要不要 + * 显示旁白,而这块面板回答的是「她将收到的是什么」:按那个偏好把旁白藏起来,就等于让用户在按下发送 + * 之前看不到这次要发出去的一部分内容——而 W61 审查发现的那处「预览里是标签、气泡里只剩正文」,错的 + * 正是同一件事(看到的不是要发的)。要改成与气泡一样随时可以,但那是主人拍板的事,不在这里顺手做。 + */ +@Composable +private fun ShareConfirmAnnotation(parts: List, onTextLayout: (TextLayoutResult) -> Unit) { + val text = parts.joinToString(" ") { it.text }.trim() + if (text.isEmpty()) return + Text( + text = text, + style = MiuixTheme.textStyles.footnote1, + fontStyle = FontStyle.Italic, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + onTextLayout = onTextLayout, + ) } /** @@ -499,8 +623,8 @@ private fun ShareConfirmBody( * 所以非有限的那几个取值([Dp.Infinity] 与 [Dp.Unspecified],后者是 `NaN`)**与「还没量到」同等对待**, * 一律回落 [SHARE_PREVIEW_MAX_HEIGHT]:这条 KDoc 写的是一条**判据**,而不该只是对调用方的请求——本函数 * 是这一层唯一的出口,它自己要能保证「给出去的上限总是有限」。今天唯一的调用方传的是 `null` 或 - * `getLineBottom(...).toDp()`(后者恒有限),所以这一道守卫不改变任何现状;它挡的是**下一次**有人 - * 顺手把 `Dp.Infinity` 当「不设上限」传进来(那正是 W58 第一次崩溃的形状)。 + * [sharePreviewWindowHeight] 的结果(正数 × 三,恒有限),所以这一道守卫不改变任何现状;它挡的是 + * **下一次**有人顺手把 `Dp.Infinity` 当「不设上限」传进来(那正是 W58 第一次崩溃的形状)。 */ internal fun shareConfirmWindowCap(measured: Dp?): Dp = measured?.takeIf { it.value.isFinite() } ?: SHARE_PREVIEW_MAX_HEIGHT @@ -529,8 +653,7 @@ internal val SHARE_PREVIEW_EXPANDED_MAX_HEIGHT = 240.dp /** * 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。 * - * 它同时是「要不要给出展开控件」的那条线([sharePreviewTextOverflowsWindow]):正好这么多行就没有 - * 东西可展开。 + * 它同时是窗口高度那笔账里唯一的数:窗口高 = **量到的行高** × 这个数([sharePreviewWindowHeight])。 * * `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有 * Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。 @@ -547,18 +670,67 @@ internal const val SHARE_PREVIEW_EXPAND_LABEL = "展开" internal const val SHARE_PREVIEW_COLLAPSE_LABEL = "收起" /** - * 正文**排下来**超过三行窗口了吗(**纯函数**,可单测)——也就是「要不要给出展开控件」。 + * 三行窗口有多高(**纯函数**,可单测):量到的一行高度 × [SHARE_PREVIEW_TEXT_LINES]。 * - * 判据是**行数**,不是 `hasVisualOverflow`:正文这一窗的裁剪落在它外面那层 `Column` 的 - * `heightIn(max = cap)` 上,而这一窗自己的排版宽度是满宽、高度不受限(`verticalScroll` 给子项的 - * 就是无上限约束),所以 `Text` 自己**永远**报「没有溢出」——拿它当判据会在最长的那种正文上漏掉 - * 控件。行数则与上限无关:只要排下来比窗口多一行,第四行就一定在窗口之外,展开就有东西可看。 - * - * 边界是**严格大于**:正好三行时整段都在窗里(`getLineBottom(2)` 就是这一窗的底),此时给一枚 - * 「展开」是点了没有反应的假控件。 + * 行高只能是**量**出来的(见 [ShareConfirmBody] 的「为什么量行高,而不是写一个 dp 常量」),所以这里 + * 只做乘法、不掺任何 dp 常量:大字体档下量到的行高更大,窗口就跟着更高,「三行」始终是三行。把这个数 + * 留在唯一一处,改它只改 [SHARE_PREVIEW_TEXT_LINES]。 */ -internal fun sharePreviewTextOverflowsWindow(lineCount: Int): Boolean = - lineCount > SHARE_PREVIEW_TEXT_LINES +internal fun sharePreviewWindowHeight(lineHeight: Dp): Dp = + lineHeight * SHARE_PREVIEW_TEXT_LINES + +/** + * 预览正文区该渲染成哪几块(**纯函数**,可单测)——**走的就是气泡那一套解析**(W65 起)。 + * + * 用的是 `MessageBubble` 里那同**一个** [parse],连兜底都是同一条:解析结果为空(或全是被丢掉的空块) + * 时回落到「整段原样显示」的那一块,与那边 `parsed.ifEmpty { listOf(plainBubble(content)) }` 逐字同义。 + * 所以「预览里看不到那一串标签」不是本函数自己剥出来的,而是 [parse] 的既有语义(落单标签一律剥掉、 + * 完整块切气泡、旁白另成一类)——这里一个字符都不改写、也不另写一套「去标签」。 + * + * **兜底不是可选项**:`parse` 对某些输入会回空列表(纯空白是既有的一档,落单标签被剥光是另一档, + * 见 `ShareTextPipelineTest` 里的 `

` 那几条),而面板的 [ShareConfirmPreview.hasText] 只管 + * 「正文非空白」,管不到解析结果。少了这条兜底,用户就会在一块**空白**前面按发送——而正文这一栏是他按 + * 发送之前唯一能核对的东西。 + * + * 正文在这里**不再 trim**:两侧空白早在 [shareConfirmPreview] 那一站去过一次(`share.text.trim()`, + * 与 `sendShared` 开头同一个口径),这里再来一次只会多一个说法不一的出处。 + */ +internal fun sharePreviewBlocks(text: String): List = + parse(text).filterNot { it.isEmpty }.ifEmpty { + listOf(ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList())) + } + +/** + * 这一窗**装不下**里面的内容吗(**纯函数**,可单测)——也就是「要不要给出展开控件」。 + * + * ## 判据是**高度**,不是行数(W65 换掉的) + * W65 之前这里数的是**一个 `Text` 的行数**(`lineCount > SHARE_PREVIEW_TEXT_LINES`)。正文改走解析 + * 之后这一窗里是**多个块**,每块还各自带旁白行与块间距,于是行数不再是一个覆盖得住窗口的量: + * + * - 旁白行与块间距都占高度,却谁也不属于哪一块的「行数」; + * - 折行位置、字号与系统大字体缩放都落在行数之外:「两个块各占两行、中间一层间距」这种最常见的形状, + * 按行数只能说「4 > 3」,说不出它到底差多少,也说不出一块内部的折行吃掉了第二块的位置; + * - 反过来也漏:「一块两行 + 一条两行的旁白」数出来是 4 行,但它真正超出的高度取决于那两层间距与 + * 旁白自己的行高——行数这个量从头到尾没有把它们算进去。 + * + * 所以比的是**两边真实排出来的像素**:内容自然高度(由挂在 `verticalScroll` 里面的 + * [androidx.compose.ui.layout.onSizeChanged] 读到,见 [ShareConfirmBody])vs 这一窗的高度 + * ([sharePreviewWindowHeight] 那个口径:量到的行高 × 三)。「自然高度 > 窗口高度」**就是**「窗里还有 + * 东西没露出来」,正是那枚「展开」承诺的事。它可自证的地方在于:两个量都是这一帧真实排出来的像素, + * 没有任何一处是我们估的行数或猜的 dp。 + * + * 也不是 `hasVisualOverflow`:裁剪落在这一窗外面那层 `Column` 的 `heightIn(max = cap)` 上,而每个 + * `Text` 自己的排版宽度是满宽、高度不受限(`verticalScroll` 给子项的就是无上限约束),所以它们永远报 + * 「没有溢出」——拿它当判据会把控件在最长的那种正文上漏掉。 + * + * ## 边界与首帧 + * 严格大于:**正好一样高**就是整段都在窗里(窗口底下一条不多),此时给一枚「展开」是点了没有反应的 + * 假控件。 + * + * 任何一边还没量到(`0`)都回 `false`:首帧摆上一枚点了没有反应的「展开」比晚一帧出现更糟。 + */ +internal fun sharePreviewTextOverflowsWindow(contentHeightPx: Int, windowHeightPx: Int): Boolean = + contentHeightPx > 0 && windowHeightPx > 0 && contentHeightPx > windowHeightPx /** * 预览区当前该给多高(**纯函数**,可单测):收起态 [SHARE_PREVIEW_MAX_HEIGHT]、展开态 diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt index 3560a7c..1fa9ecf 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt @@ -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 diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareTextPipelineTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareTextPipelineTest.kt index aed4366..51b3b56 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareTextPipelineTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareTextPipelineTest.kt @@ -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. **正文里的标记按既有语义走,且绝不抛异常**:分享进来的那段字里带 `

` / `` / 星号时, * 渲染结果由 `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` 之后**原样显示**、不过解析器,而发送后的气泡过解析器。分享一段 + * `"

正文

"` 进来,**预览里是一串标签、气泡里只剩「正文」**——用户按发送之前看到的不是他 + * 将要发出的东西(这不是崩溃,是当时如实记下的一处不一致)。 + * + * W65 把这条口径改掉了:预览按**与气泡同一套解析**渲染(多段 → 多个块,`` / `` + * → 灰斜体小字,星号规则同源)。所以本文件原本如实记下的那处不一致(旧用例 + * `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`,标记按既有语义被剥离/降级成旁白。 - // 于是 `"

正文

"` 这样的分享:预览里是一串标签,气泡里只剩「正文」。 - // 不崩、也不丢「正文」,但预览那句「她将收到的是什么」在这里是打了折的。 - listOf("

正文

", "看着窗外", "*动作*").forEach { raw -> - val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw) - assertEquals(raw, shareConfirmPreview(share).text) + /** + * 预览里**看得见的字**:按渲染顺序(每块:上依附的旁白 → 正文 → 下依附的旁白),空白的不算。 + * + * 这个顺序就是 `ShareConfirmPanel` 里 `ShareConfirmBlock` 的排法——用它当「用户看到的是什么」, + * 好让下面几条断言能直接钉住**可见文本**,而不只是内部结构。 + */ + private fun visibleLines(blocks: List): List = 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( + "

正文

", + "

第一段

第二段

第三段

", + "看着窗外", + "

说话

顿了顿", + "第一段\n\n第二段", + "1 < 2 和 a + 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 } }, + ) + + // 标记一个字符都不许出现在**看得见的地方**:预览此前把 `

` / `

` 原样摆给用户看, + // 那正是这一批要修的东西。 + visibleLines(rendered).forEach { line -> + assertFalse("标记漏进了可见文本:$line", line.contains("`、只有一个 `

`(服务端确实会这样产出)。 + // 旧口径:预览是 `"

正文

"` 这一串字符;气泡里只剩「正文」。 + // 新口径(W65):预览也走 `parse`,两边都是「正文」,一个标签字符都不显示。 + val raw = "

正文

" + 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`() { + // 多段 ⇒ 多个块:旧口径下 `

甲

乙

` 在预览里是**一行还带着标签的原样文本**,现在与 + // 气泡里一样是两块。切段不是面板新写的规则,是 `parse` 的既有语义。 + val three = ShareRules.extract( + action = ShareRules.ACTION_SEND, + text = "

第一段

第二段

第三段

", + ) + 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("

甲

乙

丙

").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`() { + // `` / `` 在预览里与气泡里一样**不进正文**:那一块没有正文,这一段落在 head + // 上,由 `ShareConfirmAnnotation` 渲染成灰斜体小字。标签本身一个字符都不显示。 + val action = sharePreviewBlocks("看着窗外").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("旁白一句").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("顿了顿

说话

")), + ) + } + + @Test + fun `a shared text the parser drops entirely is still shown verbatim`() { + // 解析结果为空时**必须有兜底**(原样显示,不是一块空白):正文那一栏是用户在按发送之前唯一能 + // 核对的东西。兜底与气泡那一侧同一条(`MessageBubble`:`parsed.ifEmpty { plainBubble(content) }`) + // ——都显示**原样那一段字**,哪怕它本来就是几个没配对的标签。 + val raw = "

" + 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