From 514901c12def6df881cb2d75aafc3f98ef9b035e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 20:09:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(share):=20=E7=A1=AE=E8=AE=A4=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E7=9A=84=E8=BE=93=E5=85=A5=E6=A1=86=E6=94=B9=E6=88=90?= =?UTF-8?q?=E3=80=8C=E7=94=A8=E6=88=B7=E9=99=84=E5=8A=A0=E7=9A=84=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E3=80=8D=E2=80=94=E2=80=94=E4=B8=8D=E5=86=8D=E9=A2=84?= =?UTF-8?q?=E5=A1=AB=E5=88=86=E4=BA=AB=E6=AD=A3=E6=96=87=EF=BC=8C=E5=8F=91?= =?UTF-8?q?=E9=80=81=E6=97=B6=E7=94=B1=E6=AD=A3=E6=96=87+=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E6=8B=BC=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W53X 的第一版把那个输入框当成了分享正文的可编辑副本(`shareConfirmDraft(share)` 直接返回分享 正文、由 ChatScreen 预填进去),在真机上就渲染成「同一句话排两遍」:预览的「分享的文字」块一遍、 输入框里又一遍,而输入框里那句还看着像一段只读展示。主人澄清语义:那个框是**用户要附加的描述** ——「这次要发什么」归预览,「你想补一句什么」才归这个框,两件事被合成一件才是根因。 改成: - 输入框初值**恒为空**(`shareCaption` 在取走一份分享时显式归零),占位符改成「加一句描述…」 (与输入栏那句「和昔涟说点什么…」同一个 `useLabelAsPlaceholder` 写法);预览区一个字没动 (图片缩略图 / 文件 chip / 分享正文都留着,那才是这次要发出去的东西)。 - 新增纯判据 `shareConfirmBody(share, caption)` = 分享正文 + 用户描述:两段都在时用**一个空行** 分隔,只有一段就只用那一段,都空就是空串(两侧空白按 `ShareRules.extract` / `sendShared` 的口径归一,中间一个字不动,也不加任何来源标记——那是项目红线)。 - 删掉 `shareConfirmDraft`(名字说「草稿/正文」、行为是「把正文当输入框初值」,留着就是骗人); `shareConfirmCanSend(share, caption)` 改成**直接问 `shareConfirmBody`** 非空、或另有图片/文件, 于是「发送」可点与否与 `sendShared` 开头那条早退同源;描述为空时不再误把「纯文本分享」判成不可发。 - ChatScreen 的发送口由 `share.copy(text = shareDraft)` 改成 `share.copy(text = shareConfirmBody(share, shareCaption))`;接线语义(`take()` 只发生一次、 面板渲染位置、WindowDialog 选型)一字未动。 测试:`ShareConfirmTest` 跟着改到新语义,拼接规则**穷举**钉住(都有 / 只有正文 / 只有描述 / 都空, 分隔符写成字面量 `"\n\n"`),并钉住「都空且无文件 ⇒ 不能发」与「可点 ≙ 拼出来的正文非空或另有 附件」(同源性)。10 条测试全绿。 验证:`./gradlew.bat --console=plain assembleDebug testDebugUnitTest` → BUILD SUCCESSFUL, 110 suite / 1332 tests / 0 失败(基线 110 / 1330,本批净增 2 条)。 --- .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 37 ++-- .../yeij/cyrene/ui/chat/ShareConfirmPanel.kt | 112 ++++++++---- .../yeij/cyrene/ui/chat/ShareConfirmTest.kt | 164 +++++++++++++----- 3 files changed, 215 insertions(+), 98 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index b9a89ec..d611609 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -442,10 +442,11 @@ fun ChatScreen( // 键(sessionId)与旁边两个等待标志同一个口径:换会话等于换一格。别的会话的 VM 也还活着, // 这份内容绝不能被另一个会话的面板认领去发——那会把分享发进错的会话。 var shareConfirm by remember(sessionId) { mutableStateOf(null) } - // 面板输入框里的文字(初值 = 分享进来的正文本身,见 [shareConfirmDraft])。它与上面那份内容 - // **分开存**是刻意的:预览要显示的是「分享进来的是什么」,用户改的是「这次要发出去的正文」, - // 改过之后这两件事就不再相同,合成一个字段会让预览跟着输入框一起变。 - var shareDraft by remember(sessionId) { mutableStateOf("") } + // 面板输入框里的文字 = **用户附加的描述**(W53X3 更正:它不再是分享正文的复刻,初值恒为空)。 + // 它与上面那份内容**分开存**是刻意的:预览那一份说的是「分享进来的是什么」,这格说的是 + // 「用户想补一句什么」,两者本来就是两件事,合成一个字段会让预览跟着输入框一起变。 + // 真正发出去的正文由纯函数 [shareConfirmBody] 在按下「发送」那一刻拼(分享正文 + 这段描述)。 + var shareCaption by remember(sessionId) { mutableStateOf("") } LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) { if (pendingShares.isEmpty() || shareConnection == WsState.Connected) { shareWaitedLong = false @@ -476,7 +477,9 @@ fun ChatScreen( if (shareConfirm == null) { ShareIntake.take()?.let { share -> shareConfirm = share - shareDraft = shareConfirmDraft(share) + // 描述从空开始:它是用户要附加的话,**不是**这次分享进来的正文—— + // 预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。 + shareCaption = "" } } } @@ -821,29 +824,33 @@ fun ChatScreen( // 分享进来的内容的**确认面板**(W53X:「一到就发」那条老路已废)。面板开着时**一个字节都还 // 没发出去**:点「发送」才走既有发送路径,点「取消」这一份就作废。这一处只做接线——内容与 - // 草稿都在上面那格 Compose 状态里,面板本身与它的判据都在 `ShareConfirmPanel.kt`。 + // 描述都在上面那格 Compose 状态里,面板本身与它的判据都在 `ShareConfirmPanel.kt`。 // // ⚠️ 取走是不可逆的(`ShareIntake.take()` 没有归还路径),所以「取消」**不把内容塞回队列**: // 塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也分不开了。 shareConfirm?.let { share -> ShareConfirmPanel( share = share, - draft = shareDraft, - onDraftChange = { shareDraft = it }, + caption = shareCaption, + onCaptionChange = { shareCaption = it }, onSend = { // 先收面板再发:这一次发送与面板本身互不相干,而它紧接着的那次重组里已经不该在了。 - val sent = share.copy(text = shareDraft) + // + // 正文 = 分享自带的那一段 + 用户补的那一句,由纯函数 [shareConfirmBody] 拼 + // (两段都在时**空行**分隔):W53X3 起输入框只代表「用户附加的描述」,所以这里 + // **不能**再直接拿输入框的内容当正文——那正是「同一句话排两遍」那一版的错法。 + val sent = share.copy(text = shareConfirmBody(share, shareCaption)) shareConfirm = null - shareDraft = "" - // 告知按**真正发出去的那一份**算(`sent`,不是原始的那一份):正文可能刚被用户在 - // 输入框里改过,拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符 - // 的话。`shareNoticeFor` 本身一个字没改,`sendShared` 里「超长正文 → 文件」那条裁决 - // 也一字未动。 + shareCaption = "" + // 告知按**真正发出去的那一份**算(`sent`,不是原始的那一份):正文里可能刚多了用户 + // 补的那一句,拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符 + // 的话。(`shareNoticeFor` 本身一个字没改,`sendShared` 里「超长正文 → 文件」那条裁决 + // 也一字未动——现在参与那条裁决的是**拼好之后**的整段正文,那才是真发出去的东西。) viewModel.sendShared(sent, shareNoticeFor(sent)) }, onCancel = { shareConfirm = null - shareDraft = "" + shareCaption = "" }, ) } 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 dce93d4..29ae596 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 @@ -67,17 +67,23 @@ import top.yukonga.miuix.kmp.window.WindowDialog * (`onDismissRequest`,这里接的正是「取消」)。这里要的只是一次确认,不需要底部面板那套 * 可拖拽的手势。 * - * ## 预览与输入框的分工 - * 上半是**预览**(图片缩略图 / 文件芯片 / 分享正文),下半是**输入框**(初值就是分享进来的正文, - * 见 [shareConfirmDraft])与「取消 / 发送」。正文在预览里**与**输入框里各出现一次是刻意的: - * 预览里那一份是「分享进来的是什么」,用户改了输入框之后它还在(否则改完就再也看不出原文了)。 - * 输入框里那一份才是**真正会发出去**的那段文字(`share.copy(text = 输入框内容)`)。 + * ## 预览与输入框的分工(W53X3 更正:输入框是「用户附加的描述」,**不是**正文的复刻) + * 上半是**预览**:这次分享进来的东西本身——图片缩略图 / 文件芯片 / **分享自带的正文**。那才是这次 + * 要发出去的内容,留着它用户才知道「她将收到的是什么」。 + * 下半是**输入框**:**你**要附加的描述,初值**恒为空**(`caption` 由调用方给空串),空框时显示 + * 占位符 [SHARE_CONFIRM_CAPTION_PLACEHOLDER](与输入栏那句「和昔涟说点什么…」同一个写法)。 + * 发送时真正发出去的正文由 [shareConfirmBody] 拼:分享正文(若有)+ 用户描述(若有),两段都在时 + * **空行分隔**,只有一段就只用那一段。 + * + * ⚠️ 这里曾经把两件事搞成一件(W53X 的第一版):输入框被预填成分享进来的正文,在真机上就渲染成 + * 「同一句话排两遍」——预览的「分享的文字」块一遍、输入框里又一遍,而输入框里那句还看着像一段 + * 只读展示。错在「这次要发什么」与「你想补一句什么」本来是两件事:前者归预览,后者才归这个输入框。 */ @Composable fun ShareConfirmPanel( share: SharedContent, - draft: String, - onDraftChange: (String) -> Unit, + caption: String, + onCaptionChange: (String) -> Unit, onSend: () -> Unit, onCancel: () -> Unit, ) { @@ -96,10 +102,10 @@ fun ShareConfirmPanel( Spacer(Modifier.height(12.dp)) } TextField( - value = draft, - onValueChange = onDraftChange, + value = caption, + onValueChange = onCaptionChange, modifier = Modifier.fillMaxWidth(), - label = SHARE_CONFIRM_DRAFT_LABEL, + label = SHARE_CONFIRM_CAPTION_PLACEHOLDER, useLabelAsPlaceholder = true, maxLines = 4, ) @@ -112,10 +118,10 @@ fun ShareConfirmPanel( TextButton( text = "发送", onClick = onSend, - // 什么都没有可发时这枚键不该可点:`sendShared` 在这种输入下会**原地返回** - // (一次发送都不会发生),可点的键会让人以为按下去有用。判据是纯函数 - // [shareConfirmCanSend],与那条早退**同一条**。 - enabled = shareConfirmCanSend(share, draft), + // 什么都没有可发时这枚键不该可点:「发送」发出去的那段正文就是 [shareConfirmBody] + // 拼出来的那一段,而 `sendShared` 在「正文空白 + 没有图片 + 没有文件」时会**原地 + // 返回**(一次发送都不会发生)。判据是纯函数 [shareConfirmCanSend],与那条早退同源。 + enabled = shareConfirmCanSend(share, caption), ) } } @@ -161,43 +167,74 @@ fun shareConfirmPreview(share: SharedContent): ShareConfirmPreview = ShareConfir ) /** - * 确认面板里那个输入框的**初值**(**纯函数**,可单测):就是分享进来的正文本身。 + * 点「发送」时**真正发出去的那段正文**(**纯函数**,可单测):分享自带的正文 + 用户附加的描述。 * - * 分享进来的东西**一个字都不许丢**:从浏览器分享一个链接进来时,`EXTRA_TEXT` 里那段地址就是 - * 用户想发的东西,把它留在别处(或者干脆丢掉、让用户自己再打一遍)都是在替用户删掉他要发的内容。 + * 三段穷举(这也是它可测的全部形状): + * - 两段都有 ⇒ `分享正文` + [SHARE_CONFIRM_BODY_SEPARATOR] + `用户描述`(**空行**分隔:两段话挨在 + * 一起时读者分不清哪句是谁说的;空行是「这是两段」的最小表达,也让正文里本来换行的人不受影响); + * - 只有一段 ⇒ 就是那一段(**不补分隔符**:给一段话收个尾是凭空多出来的东西); + * - 都空 ⇒ 空串(此时能发的只剩图片 / 文件,正文那一栏本来就该空着)。 * - * 而它也**不许加任何东西**——不拼「[用户分享]」之类的来源标记、不加引号、不套模板。来源标识走 - * 后端的独立 `RoleSystem` 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`),人类可见的 - * 标记只能放在本端渲染层;把标记拼进正文 = 让用户正文冒充系统指令,那是项目红线。所以这里 - * 与 [shareConfirmPreview] 的 `text` 是**同一个值**,两者不可能分叉。 + * **内容一字不丢**是红线,这一条覆盖三件事: + * 1. 分享正文按 [shareConfirmPreview] 的口径两侧去空白 —— 与 `ShareRules.extract` 取正文、 + * 以及 `sendShared` 开头 `share.text.trim()` 是**同一个**口径,所以「这里非空」≙「那边发得出去」; + * 2. 用户描述同样只去两侧空白,**中间一个字不动**(用户自己换的行是他打的,不是我们排版的地方); + * 3. 一律**不加**来源标记 —— 不拼「[用户分享]」、不加引号、不套模板。来源标识走后端的独立 + * `RoleSystem` 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`);把标记拼进正文 + * = 让用户正文冒充系统指令,那是项目红线。 + * + * 顺序是「分享正文在前、用户描述在后」:先有「她收到的那份东西」,再有「你补的那句话」——反过来的 + * 话,用户想给一条链接加注释就得先读注释再读链接,而他要注释的正是那条链接。 */ -fun shareConfirmDraft(share: SharedContent): String = shareConfirmPreview(share).text +fun shareConfirmBody(share: SharedContent, caption: String): String { + val shared = shareConfirmPreview(share).text + val own = caption.trim() + return when { + shared.isEmpty() -> own + own.isEmpty() -> shared + else -> shared + SHARE_CONFIRM_BODY_SEPARATOR + own + } +} + +/** + * 两段正文之间的分隔(见 [shareConfirmBody]):**一个空行**(`\n\n`),不是空格、也不是一行 `——`。 + * + * 只在**两段都在**时出现:`\n\n` 是任何聊天客户端都认的「两段」写法,而多出来的装饰性分隔行 + * (`---`、`· · ·`)会被对面当成内容读进去。 + */ +const val SHARE_CONFIRM_BODY_SEPARATOR = "\n\n" /** * 面板里那枚「发送」该不该可点(**纯函数**,可单测)。 * - * 判据与 `ChatViewModel.sendShared` 开头那条早退**是同一条**:正文(去掉空白后)为空、图片为空、 - * 文件也为空时,那个方法会原地 `return`——一次发送都不会发生。此时把键画成可点的,用户按下去只会 - * 看到面板关掉、什么也没发生,而他会以为自己少发了一条。所以这里回 `false`。 + * 判据与 `ChatViewModel.sendShared` 开头那条早退**是同一条**:那边算的是 + * `share.text.trim()`(也就是本端 [shareConfirmBody] 会拼进去的那一段)非空、或图片非空、或文件非空; + * 三样都空时它原地 `return`——一次发送都不会发生。此时把键画成可点的,用户按下去只会看到面板关掉、 + * 什么也没发生,而他会以为自己少发了一条。所以这里回 `false`。 + * + * 正文那一支直接问 [shareConfirmBody](**同一个**出处,不是另写一遍 `text + caption` 的非空判断): + * 两条各写各的判据迟早会分叉,那时按钮会在「真发得出去」的时候变灰,或者反过来。 * * ⚠️ 三样里**任意一样非空就可发**:纯图片 / 纯文件的回合服务端**支持**(契约:`text` / `image` / * `documents` 任一项非空即可),本端也照旧只发得出去的那几样,不因为「没写文字」而拦下它。 */ -fun shareConfirmCanSend(share: SharedContent, draft: String): Boolean { +fun shareConfirmCanSend(share: SharedContent, caption: String): Boolean { val preview = shareConfirmPreview(share) - return draft.isNotBlank() || preview.images.isNotEmpty() || preview.documents.isNotEmpty() + return shareConfirmBody(share, caption).isNotEmpty() || + preview.images.isNotEmpty() || + preview.documents.isNotEmpty() } /** 面板标题(与顶栏那句「与昔涟」同一称呼)。 */ const val SHARE_CONFIRM_TITLE = "发送给昔涟" /** - * 输入框的标签(`useLabelAsPlaceholder`:**只在框里没字时**显示)。 + * 输入框的占位符(`useLabelAsPlaceholder`:**只在框里没字时**显示)。 * - * 分享带正文时框里已经有字、这句标签看不见,但那正是用户能改的那段文字(它是一个可编辑的输入框, - * 不是一段只读的展示):改它等于改这次要发出的正文。 + * 与输入栏那句「和昔涟说点什么…」同一个写法、同一个语气:它说的是**你**要补一句什么。 + * 框里的字是用户自己打的、默认没有(初值恒为空)——所以这句占位符是常态而不是罕见档。 */ -const val SHARE_CONFIRM_DRAFT_LABEL = "附一句话(可以修改)" +const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…" /** * 预览区(图片 / 文件 / 正文)。 @@ -211,9 +248,14 @@ const val SHARE_CONFIRM_DRAFT_LABEL = "附一句话(可以修改)" * 输入框与按钮原地不动。 * * ## 正文为什么只给三行 - * 预览只是「分享进来的是什么」的一眼,**可读的全文在下面那个输入框里**(它是预填的、可滚动可编辑)。 - * 预览若把一段几万字的正文整个铺开,这个面板就成了一个阅读器,而用户要的是按一下发送。 - * 三行之外用省略号收口,不假装那是全文。 + * 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板 + * 就成了一个阅读器。三行之外用省略号收口,不假装那是全文。 + * + * ⚠️ W53X3 之后**面板里再没有第二个地方放着这段正文**:输入框已经改成「用户附加的描述」(初值恒为 + * 空),不再是正文的可编辑副本。也就是说超过三行的分享正文在面板上只看得到开头——**这不影响发出去的 + * 内容**([shareConfirmBody] 用的是完整的那一段,一字不丢),但它确实少了一处「发送前读全文」的地方。 + * 这是 W53X 那一版的老行为,本批只把它如实写下来、**没有顺手改**:正文块要不要能展开属于观感取舍, + * 得由主人拍板。 */ @Composable private fun ShareConfirmPreviews(preview: ShareConfirmPreview) { @@ -275,7 +317,7 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) { /** 预览区的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */ private val SHARE_PREVIEW_MAX_HEIGHT = 150.dp -/** 预览里正文给几行(全文在下面那个输入框里)。 */ +/** 预览里正文给几行(超出的部分收成省略号,见 [ShareConfirmPreviews] 的 KDoc)。 */ private const val SHARE_PREVIEW_TEXT_LINES = 3 /** 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 4d7a1c0..36ddf5a 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 @@ -8,19 +8,30 @@ import top.yeij.cyrene.domain.share.SharedContent import top.yeij.cyrene.domain.share.SharedDocument /** - * 分享确认面板的三条判据([shareConfirmPreview] / [shareConfirmDraft] / [shareConfirmCanSend])。 + * 分享确认面板的三条判据([shareConfirmPreview] / [shareConfirmBody] / [shareConfirmCanSend])。 * * 背景:主人 2026-10-06 拍板——分享进来的内容不再「一到就发」,要像其他 IM 一样先给一个确认面板。 - * 面板本身是 Compose 的东西、只能在真机上看(观感由主人验收),但它**该显示什么**、**输入框从哪 - * 个值开始**、**「发送」是否可点**这三件事不该只活在 Composable 里:它们决定了「用户看到的是不是 - * 他要发的那一份」。本测试钉住四件事: + * 面板本身是 Compose 的东西、只能在真机上看(观感由主人验收),但它**该显示什么**、**发送时正文 + * 是哪几段拼起来的**、**「发送」是否可点**这三件事不该只活在 Composable 里:它们决定了「用户看到 + * 的是不是他要发的那一份」。本测试钉住四件事: * * 1. **内容一样都不少**:图片与文件按分享进来的顺序原样进预览(多张图的顺序就是发送顺序); * 2. **空的也算没有**:空地址画出来是一个坏掉的缩略图,全空白的正文占着一行什么也不说; - * 3. **输入框的初值就是分享进来的正文本身,一个字不加**——这是红线:往用户正文里拼来源标记 - * (「[用户分享]」之类)等于让用户正文冒充系统指令(`02/F4` / `design/11 §10`); + * 3. **拼接规则穷举**([shareConfirmBody]):分享正文 + 用户描述,两段都在时**一个空行**分隔、 + * 只有一段就只用那一段、都空就是空串——**一个字不许丢、也不许加**; * 4. **「发送」可点 ≙ 真的会有东西发出去**:与 `sendShared` 开头那条早退同一条判据——那边在 * 「正文空白 + 没有图片 + 没有文件」时原地返回,一次发送都不会发生。 + * + * ## W53X3 改掉了什么(读这份测试时要先知道) + * 输入框的语义此前被搞反过:一度被**预填成分享进来的正文**,于是面板在真机上把同一句话排两遍 + * (预览的「分享的文字」块一遍、输入框里又一遍)。主人澄清后更正为:那个框是「**用户附加的描述**」 + * 输入框,初值**恒为空**,发送时与分享正文**拼接**。所以 [shareConfirmBody] 取代了原来那个 + * `shareConfirmDraft(share) = 分享正文`(那个函数的名字与行为此后都不对了:它曾经让输入框里那一份 + * 「才是真正会发出去的文字」,现在真正发出去的是拼出来的那一段)。 + * + * ⚠️ 「输入框初值为空」本身是 Compose 状态(`ChatScreen` 里那格 `remember`),单测碰不到——它由 + * 主人真机验收(输入框空着、预览里有正文)。这份测试能钉住的是它的**后果**:描述为空时,拼出来的 + * 正文就是分享正文本身、而且只出现**一次**(见 `the caption is not a copy of the shared text`)。 */ class ShareConfirmTest { @@ -70,46 +81,74 @@ class ShareConfirmTest { } @Test - fun `the draft starts as the shared text itself, with nothing added`() { - // 从浏览器分享一个链接进来:那段地址就是用户要发的东西,初值一字不差就是它。 + fun `the two pieces are joined by exactly one blank line, and the rule is exhaustive`() { + val shared = SharedContent(text = "看这个") + val empty = SharedContent() + + // ① 两段都有:分享正文 + 一个空行 + 用户描述。分隔符写死成字面量 `"\n\n"`——用常量去断言 + // 常量等于什么都没钉(把这个常量改成空格,测试照样绿)。 + assertEquals("看这个\n\n这句话不错", shareConfirmBody(shared, "这句话不错")) + // ② 只有分享正文:就是那一段,**不补分隔符**(给一段话收个尾是凭空多出来的东西)。 + assertEquals("看这个", shareConfirmBody(shared, "")) + // ③ 只有用户描述:纯图片 / 纯文件的分享就靠这一档带话(此时分享正文是空的)。 + assertEquals("这句话不错", shareConfirmBody(empty, "这句话不错")) + // ④ 都空:空串(能发的只剩图片 / 文件,正文那一栏本来就该空着)。 + assertEquals("", shareConfirmBody(empty, "")) + + // 分隔符本身也钉一下:它只有一种写法,改了这里就是改了「两段」这个语义。 + assertEquals("\n\n", SHARE_CONFIRM_BODY_SEPARATOR) + } + + @Test + fun `neither piece is rewritten, only the whitespace around them goes`() { + // 分享正文里用户自己的换行、空行**一个字不动**(只去两侧空白,与 ShareRules.extract 取正文 + // 同一个口径);用户描述同样只去两侧空白,中间的换行是他打的,不是我们排版的地方。 + val multiLine = "第一段\n\n第二段" + assertEquals(multiLine, shareConfirmBody(SharedContent(text = multiLine), "")) + assertEquals( + "$multiLine\n\n我补的\n\n第三行", + shareConfirmBody(SharedContent(text = multiLine), " 我补的\n\n第三行 "), + ) + + // 两侧空白归一:正文里那点空白不该原样进聊天记录(`sendShared` 开头同样 `trim()`)。 + assertEquals("看这个", shareConfirmBody(SharedContent(text = " 看这个 "), "")) + assertEquals("补一句", shareConfirmBody(SharedContent(), " 补一句 ")) + assertEquals("看这个\n\n补一句", shareConfirmBody(SharedContent(text = " 看这个 "), " 补一句 ")) + + // **只有空白的描述算没有描述**:否则会拼出一个「正文 + 空行 + 什么也没有」的尾巴, + // 而那是我方凭空加的东西(内容一字不丢的另一面是:一个字也不许多)。 + assertEquals("看这个", shareConfirmBody(SharedContent(text = "看这个"), " ")) + assertEquals("看这个", shareConfirmBody(SharedContent(text = "看这个"), "\n")) + assertEquals("", shareConfirmBody(SharedContent(text = " "), " \n ")) + } + + @Test + fun `the caption is not a copy of the shared text`() { val link = "https://example.com/某个页面?id=1" - assertEquals(link, shareConfirmDraft(SharedContent(text = link))) + val body = shareConfirmBody(SharedContent(text = link), "") - // 分享里本来没有正文 ⇒ 空手开始(不是一句模板、也不是一个空行)。 - assertEquals("", shareConfirmDraft(SharedContent(images = listOf("file:///cache/share-a.png")))) + // 描述是空的(输入框初值恒为空)⇒ 发出去的就是分享正文本身,**而且只出现一次**。 + // W53X 的第一版把正文预填进输入框(于是它「才是真正会发出去的文字」),面板上就渲染成 + // 同一句话排两遍——这条断言钉的是那个错法的后果:描述不预填时,正文不会在正文里出现两次。 + assertEquals(link, body) + assertEquals("分享进来的正文只能出现一次", 1, Regex(Regex.escape(link)).findAll(body).count()) - // 两侧空白归一(与 ShareRules.extract 取正文那一处同一个口径)。 - assertEquals("看这个", shareConfirmDraft(SharedContent(text = " 看这个 "))) + // 描述带字时,链接照样**逐字节**在正文里(不许被截短、不许被改写成别的形态)。 + val withCaption = shareConfirmBody(SharedContent(text = link), "这个页面我看过了") + assertTrue("分享正文必须逐字节保留", withCaption.contains(link)) + assertTrue(withCaption.endsWith("这个页面我看过了")) } @Test - fun `the draft is exactly what the preview shows`() { - // 两边必须同值:预览里那一块说的是「分享进来的是什么」,而输入框的初值就是它的可编辑副本。 - // 分成两个判据之后若各写各的,窗口里就会出现「预览显示 A、输入框里是 B」这种一眼看去 - // 分不清要发哪一份的状态。 - listOf( - SharedContent(text = "看这个"), - SharedContent(text = " 带空白的正文 "), - SharedContent(images = listOf("file:///cache/share-a.png")), - SharedContent(), - ).forEach { share -> - assertEquals( - "预览与输入框的初值必须同源", - shareConfirmPreview(share).text, - shareConfirmDraft(share), - ) - } - } - - @Test - fun `the draft never carries a source marker`() { - val draft = shareConfirmDraft(SharedContent(text = "看这个")) + fun `the body never carries a source marker`() { + val body = shareConfirmBody(SharedContent(text = "看这个"), "补一句") // 来源标识走后端的 RoleSystem 来源消息 + 结构化 SenderName,人类可见的标记只能放在本端 - // 渲染层——把它拼进正文 = 让用户正文冒充系统指令,项目红线。 - assertEquals("看这个", draft) - assertFalse(draft.contains("分享")) - assertFalse(draft.contains("[用户")) + // 渲染层——把它拼进正文 = 让用户正文冒充系统指令,项目红线。拼接这一层是最容易顺手加上 + // 「[分享]」的地方,所以这条也在这里钉住。 + assertFalse(body.contains("[用户")) + assertFalse(body.contains("分享")) + assertEquals("看这个\n\n补一句", body) } @Test @@ -119,30 +158,59 @@ class ShareConfirmTest { val image = SharedContent(images = listOf("file:///cache/share-a.png")) val document = SharedContent(documents = listOf(SharedDocument(uri = "file:///cache/share-0.txt"))) - // 用户改过的正文也算(他把分享的链接改短、或者换上自己想说的话)。 - assertTrue(shareConfirmCanSend(text, "改过的正文")) + // 分享带正文、用户没补话:正文本来就是这次要发的东西,照样可发。 + assertTrue(shareConfirmCanSend(text, "")) + // 纯文字描述也行(没有可预览的内容时,用户补的那一句就是全部内容)。 assertTrue(shareConfirmCanSend(nothing, "只写一句话也行")) + assertTrue(shareConfirmCanSend(text, "再多说一句")) // 纯图片 / 纯文件照样可发:服务端支持只带 image / documents 的回合,本端不因为「没写文字」拦它。 assertTrue(shareConfirmCanSend(image, "")) assertTrue(shareConfirmCanSend(document, "")) assertTrue(shareConfirmCanSend(image, " ")) - // 把正文整段删光,图片与文件照样发得出去:用户反悔的只是那段文字,不是这次分享的其余部分。 + // 把描述留空也不影响图片与文件:用户只是没写话,不是反悔了这次分享。 val textAndImage = SharedContent(text = "看这个", images = listOf("file:///cache/share-a.png")) assertTrue(shareConfirmCanSend(textAndImage, "")) } @Test fun `send is not offered when nothing at all would go out`() { - // 这条与 `sendShared` 开头那条早退**同一条**:正文(去空白后)为空、图片为空、文件也为空时 - // 那边原地 `return`——一次发送都不会发生。键若画成可点的,用户按下去只看到面板关掉、 - // 什么都没发生,而他会以为自己少发了一条。 + // 这条与 `sendShared` 开头那条早退**同一条**:正文([shareConfirmBody] 拼出来的那一段) + // 为空、图片为空、文件也为空时,那边原地 `return`——一次发送都不会发生。键若画成可点的, + // 用户按下去只看到面板关掉、什么都没发生,而他会以为自己少发了一条。 assertFalse(shareConfirmCanSend(SharedContent(), "")) assertFalse(shareConfirmCanSend(SharedContent(), " ")) - assertFalse(shareConfirmCanSend(SharedContent(text = "看这个"), " ")) - // 正文被删光的**纯文本分享**(一个链接分享就是这一档)就什么都不剩了——此时没有可发的 - // 东西,而这正是「发送」该变灰的那种情形。 - assertFalse(shareConfirmCanSend(SharedContent(text = "看这个"), "")) + assertFalse(shareConfirmCanSend(SharedContent(text = " "), "\n")) // 只剩「丢件计数」的分享(有 2 张图读不到)同样没有可发的东西。 assertFalse(shareConfirmCanSend(SharedContent(failedImages = 2), "")) } + + @Test + fun `the button asks the very same question as the body`() { + // 按钮的可用状态与「真发出去的那段正文」必须**同源**:两边各写一遍非空判断迟早会分叉, + // 那时键会在「真发得出去」的时候变灰(或者反过来)。这里穷举四种拼接形状,逐一核对 + // 「可点 == 拼出来的正文非空,或另有两样附件」。W53X3 之前这里比的是**输入框内容**非空 + // ——输入框只剩「用户描述」之后,那条口径会把「纯文本分享 + 没写描述」误判成不可发。 + val cases = listOf( + SharedContent(text = "看这个") to "", + SharedContent(text = "看这个") to "补一句", + SharedContent(text = " ") to " ", + SharedContent() to "", + ) + + cases.forEach { (share, caption) -> + val preview = shareConfirmPreview(share) + val expected = shareConfirmBody(share, caption).isNotEmpty() || + preview.images.isNotEmpty() || + preview.documents.isNotEmpty() + assertEquals( + "「发送」可点必须与拼出来的正文同源:$share / 「$caption」", + expected, + shareConfirmCanSend(share, caption), + ) + } + + // 上面那四档里,只有「纯文本分享 + 描述留空」这一档是新口径下发得出去的(旧口径会把它 + // 当成「用户把正文删光了」而变灰)。它单独钉一条字面量,免得将来有人把它当成回归改回去。 + assertTrue(shareConfirmCanSend(SharedContent(text = "看这个"), "")) + } }