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

@@ -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] 只是给结果加了一道兜底)。于是在这里看到的就是
* 发出去之后气泡里的样子:每个 `<p>…</p>`(无标签时按空行分段)是**一个独立的块**,
* `<action>` / `<narration>`(以及星号那几种写法)不当正文、而是**灰斜体小字**的旁白块,标签本身
* 一个字符都不显示。
*
* 这一层修掉的是 W61 审查发现的一处「所见非所发」:预览此前把正文 `trim` 之后原样显示、**不过解析
* 器**,而发送后的气泡过解析器——分享一段 `"<p><p>正文</p>"` 进来,**预览里是一串标签、气泡里只剩
* 「正文」**。用户按发送之前看到的不是他将要发出的东西。
*
* ## 正文给三行,但三行之外是「滚」不是「省略号」
* 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板
* 就成了一个阅读器。所以正文那一窗**恒为三行高**([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<Dp?>(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<ContentPart>, 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` 里的 `<p><p>` 那几条),而面板的 [ShareConfirmPreview.hasText] 只管
* 「正文非空白」,管不到解析结果。少了这条兜底,用户就会在一块**空白**前面按发送——而正文这一栏是他按
* 发送之前唯一能核对的东西。
*
* 正文在这里**不再 trim**:两侧空白早在 [shareConfirmPreview] 那一站去过一次(`share.text.trim()`,
* 与 `sendShared` 开头同一个口径),这里再来一次只会多一个说法不一的出处。
*/
internal fun sharePreviewBlocks(text: String): List<ParsedBubble> =
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]、展开态
@@ -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