fix(share): 确认面板的输入框改成「用户附加的描述」——不再预填分享正文,发送时由正文+描述拼成

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 条)。
This commit is contained in:
AskaEth committed 2026-10-06 20:09:35 +08:00
1 parent 59012f711b
commit 514901c12d
3 files changed
+215 -98

No files matched your search

@@ -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 = "看这个"), ""))
}
}