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:
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 = "看这个"), ""))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user