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
+211
-94
No files matched your search
@@ -442,10 +442,11 @@ fun ChatScreen(
|
|||||||
// 键(sessionId)与旁边两个等待标志同一个口径:换会话等于换一格。别的会话的 VM 也还活着,
|
// 键(sessionId)与旁边两个等待标志同一个口径:换会话等于换一格。别的会话的 VM 也还活着,
|
||||||
// 这份内容绝不能被另一个会话的面板认领去发——那会把分享发进错的会话。
|
// 这份内容绝不能被另一个会话的面板认领去发——那会把分享发进错的会话。
|
||||||
var shareConfirm by remember(sessionId) { mutableStateOf<SharedContent?>(null) }
|
var shareConfirm by remember(sessionId) { mutableStateOf<SharedContent?>(null) }
|
||||||
// 面板输入框里的文字(初值 = 分享进来的正文本身,见 [shareConfirmDraft])。它与上面那份内容
|
// 面板输入框里的文字 = **用户附加的描述**(W53X3 更正:它不再是分享正文的复刻,初值恒为空)。
|
||||||
// **分开存**是刻意的:预览要显示的是「分享进来的是什么」,用户改的是「这次要发出去的正文」,
|
// 它与上面那份内容**分开存**是刻意的:预览那一份说的是「分享进来的是什么」,这格说的是
|
||||||
// 改过之后这两件事就不再相同,合成一个字段会让预览跟着输入框一起变。
|
// 「用户想补一句什么」,两者本来就是两件事,合成一个字段会让预览跟着输入框一起变。
|
||||||
var shareDraft by remember(sessionId) { mutableStateOf("") }
|
// 真正发出去的正文由纯函数 [shareConfirmBody] 在按下「发送」那一刻拼(分享正文 + 这段描述)。
|
||||||
|
var shareCaption by remember(sessionId) { mutableStateOf("") }
|
||||||
LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) {
|
LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) {
|
||||||
if (pendingShares.isEmpty() || shareConnection == WsState.Connected) {
|
if (pendingShares.isEmpty() || shareConnection == WsState.Connected) {
|
||||||
shareWaitedLong = false
|
shareWaitedLong = false
|
||||||
@@ -476,7 +477,9 @@ fun ChatScreen(
|
|||||||
if (shareConfirm == null) {
|
if (shareConfirm == null) {
|
||||||
ShareIntake.take()?.let { share ->
|
ShareIntake.take()?.let { share ->
|
||||||
shareConfirm = share
|
shareConfirm = share
|
||||||
shareDraft = shareConfirmDraft(share)
|
// 描述从空开始:它是用户要附加的话,**不是**这次分享进来的正文——
|
||||||
|
// 预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。
|
||||||
|
shareCaption = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -821,29 +824,33 @@ fun ChatScreen(
|
|||||||
|
|
||||||
// 分享进来的内容的**确认面板**(W53X:「一到就发」那条老路已废)。面板开着时**一个字节都还
|
// 分享进来的内容的**确认面板**(W53X:「一到就发」那条老路已废)。面板开着时**一个字节都还
|
||||||
// 没发出去**:点「发送」才走既有发送路径,点「取消」这一份就作废。这一处只做接线——内容与
|
// 没发出去**:点「发送」才走既有发送路径,点「取消」这一份就作废。这一处只做接线——内容与
|
||||||
// 草稿都在上面那格 Compose 状态里,面板本身与它的判据都在 `ShareConfirmPanel.kt`。
|
// 描述都在上面那格 Compose 状态里,面板本身与它的判据都在 `ShareConfirmPanel.kt`。
|
||||||
//
|
//
|
||||||
// ⚠️ 取走是不可逆的(`ShareIntake.take()` 没有归还路径),所以「取消」**不把内容塞回队列**:
|
// ⚠️ 取走是不可逆的(`ShareIntake.take()` 没有归还路径),所以「取消」**不把内容塞回队列**:
|
||||||
// 塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也分不开了。
|
// 塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也分不开了。
|
||||||
shareConfirm?.let { share ->
|
shareConfirm?.let { share ->
|
||||||
ShareConfirmPanel(
|
ShareConfirmPanel(
|
||||||
share = share,
|
share = share,
|
||||||
draft = shareDraft,
|
caption = shareCaption,
|
||||||
onDraftChange = { shareDraft = it },
|
onCaptionChange = { shareCaption = it },
|
||||||
onSend = {
|
onSend = {
|
||||||
// 先收面板再发:这一次发送与面板本身互不相干,而它紧接着的那次重组里已经不该在了。
|
// 先收面板再发:这一次发送与面板本身互不相干,而它紧接着的那次重组里已经不该在了。
|
||||||
val sent = share.copy(text = shareDraft)
|
//
|
||||||
|
// 正文 = 分享自带的那一段 + 用户补的那一句,由纯函数 [shareConfirmBody] 拼
|
||||||
|
// (两段都在时**空行**分隔):W53X3 起输入框只代表「用户附加的描述」,所以这里
|
||||||
|
// **不能**再直接拿输入框的内容当正文——那正是「同一句话排两遍」那一版的错法。
|
||||||
|
val sent = share.copy(text = shareConfirmBody(share, shareCaption))
|
||||||
shareConfirm = null
|
shareConfirm = null
|
||||||
shareDraft = ""
|
shareCaption = ""
|
||||||
// 告知按**真正发出去的那一份**算(`sent`,不是原始的那一份):正文可能刚被用户在
|
// 告知按**真正发出去的那一份**算(`sent`,不是原始的那一份):正文里可能刚多了用户
|
||||||
// 输入框里改过,拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符
|
// 补的那一句,拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符
|
||||||
// 的话。`shareNoticeFor` 本身一个字没改,`sendShared` 里「超长正文 → 文件」那条裁决
|
// 的话。(`shareNoticeFor` 本身一个字没改,`sendShared` 里「超长正文 → 文件」那条裁决
|
||||||
// 也一字未动。
|
// 也一字未动——现在参与那条裁决的是**拼好之后**的整段正文,那才是真发出去的东西。)
|
||||||
viewModel.sendShared(sent, shareNoticeFor(sent))
|
viewModel.sendShared(sent, shareNoticeFor(sent))
|
||||||
},
|
},
|
||||||
onCancel = {
|
onCancel = {
|
||||||
shareConfirm = null
|
shareConfirm = null
|
||||||
shareDraft = ""
|
shareCaption = ""
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,17 +67,23 @@ import top.yukonga.miuix.kmp.window.WindowDialog
|
|||||||
* (`onDismissRequest`,这里接的正是「取消」)。这里要的只是一次确认,不需要底部面板那套
|
* (`onDismissRequest`,这里接的正是「取消」)。这里要的只是一次确认,不需要底部面板那套
|
||||||
* 可拖拽的手势。
|
* 可拖拽的手势。
|
||||||
*
|
*
|
||||||
* ## 预览与输入框的分工
|
* ## 预览与输入框的分工(W53X3 更正:输入框是「用户附加的描述」,**不是**正文的复刻)
|
||||||
* 上半是**预览**(图片缩略图 / 文件芯片 / 分享正文),下半是**输入框**(初值就是分享进来的正文,
|
* 上半是**预览**:这次分享进来的东西本身——图片缩略图 / 文件芯片 / **分享自带的正文**。那才是这次
|
||||||
* 见 [shareConfirmDraft])与「取消 / 发送」。正文在预览里**与**输入框里各出现一次是刻意的:
|
* 要发出去的内容,留着它用户才知道「她将收到的是什么」。
|
||||||
* 预览里那一份是「分享进来的是什么」,用户改了输入框之后它还在(否则改完就再也看不出原文了)。
|
* 下半是**输入框**:**你**要附加的描述,初值**恒为空**(`caption` 由调用方给空串),空框时显示
|
||||||
* 输入框里那一份才是**真正会发出去**的那段文字(`share.copy(text = 输入框内容)`)。
|
* 占位符 [SHARE_CONFIRM_CAPTION_PLACEHOLDER](与输入栏那句「和昔涟说点什么…」同一个写法)。
|
||||||
|
* 发送时真正发出去的正文由 [shareConfirmBody] 拼:分享正文(若有)+ 用户描述(若有),两段都在时
|
||||||
|
* **空行分隔**,只有一段就只用那一段。
|
||||||
|
*
|
||||||
|
* ⚠️ 这里曾经把两件事搞成一件(W53X 的第一版):输入框被预填成分享进来的正文,在真机上就渲染成
|
||||||
|
* 「同一句话排两遍」——预览的「分享的文字」块一遍、输入框里又一遍,而输入框里那句还看着像一段
|
||||||
|
* 只读展示。错在「这次要发什么」与「你想补一句什么」本来是两件事:前者归预览,后者才归这个输入框。
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ShareConfirmPanel(
|
fun ShareConfirmPanel(
|
||||||
share: SharedContent,
|
share: SharedContent,
|
||||||
draft: String,
|
caption: String,
|
||||||
onDraftChange: (String) -> Unit,
|
onCaptionChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -96,10 +102,10 @@ fun ShareConfirmPanel(
|
|||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
TextField(
|
TextField(
|
||||||
value = draft,
|
value = caption,
|
||||||
onValueChange = onDraftChange,
|
onValueChange = onCaptionChange,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = SHARE_CONFIRM_DRAFT_LABEL,
|
label = SHARE_CONFIRM_CAPTION_PLACEHOLDER,
|
||||||
useLabelAsPlaceholder = true,
|
useLabelAsPlaceholder = true,
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
)
|
)
|
||||||
@@ -112,10 +118,10 @@ fun ShareConfirmPanel(
|
|||||||
TextButton(
|
TextButton(
|
||||||
text = "发送",
|
text = "发送",
|
||||||
onClick = onSend,
|
onClick = onSend,
|
||||||
// 什么都没有可发时这枚键不该可点:`sendShared` 在这种输入下会**原地返回**
|
// 什么都没有可发时这枚键不该可点:「发送」发出去的那段正文就是 [shareConfirmBody]
|
||||||
// (一次发送都不会发生),可点的键会让人以为按下去有用。判据是纯函数
|
// 拼出来的那一段,而 `sendShared` 在「正文空白 + 没有图片 + 没有文件」时会**原地
|
||||||
// [shareConfirmCanSend],与那条早退**同一条**。
|
// 返回**(一次发送都不会发生)。判据是纯函数 [shareConfirmCanSend],与那条早退同源。
|
||||||
enabled = shareConfirmCanSend(share, draft),
|
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`),人类可见的
|
* 1. 分享正文按 [shareConfirmPreview] 的口径两侧去空白 —— 与 `ShareRules.extract` 取正文、
|
||||||
* 标记只能放在本端渲染层;把标记拼进正文 = 让用户正文冒充系统指令,那是项目红线。所以这里
|
* 以及 `sendShared` 开头 `share.text.trim()` 是**同一个**口径,所以「这里非空」≙「那边发得出去」;
|
||||||
* 与 [shareConfirmPreview] 的 `text` 是**同一个值**,两者不可能分叉。
|
* 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` 开头那条早退**是同一条**:正文(去掉空白后)为空、图片为空、
|
* 判据与 `ChatViewModel.sendShared` 开头那条早退**是同一条**:那边算的是
|
||||||
* 文件也为空时,那个方法会原地 `return`——一次发送都不会发生。此时把键画成可点的,用户按下去只会
|
* `share.text.trim()`(也就是本端 [shareConfirmBody] 会拼进去的那一段)非空、或图片非空、或文件非空;
|
||||||
* 看到面板关掉、什么也没发生,而他会以为自己少发了一条。所以这里回 `false`。
|
* 三样都空时它原地 `return`——一次发送都不会发生。此时把键画成可点的,用户按下去只会看到面板关掉、
|
||||||
|
* 什么也没发生,而他会以为自己少发了一条。所以这里回 `false`。
|
||||||
|
*
|
||||||
|
* 正文那一支直接问 [shareConfirmBody](**同一个**出处,不是另写一遍 `text + caption` 的非空判断):
|
||||||
|
* 两条各写各的判据迟早会分叉,那时按钮会在「真发得出去」的时候变灰,或者反过来。
|
||||||
*
|
*
|
||||||
* ⚠️ 三样里**任意一样非空就可发**:纯图片 / 纯文件的回合服务端**支持**(契约:`text` / `image` /
|
* ⚠️ 三样里**任意一样非空就可发**:纯图片 / 纯文件的回合服务端**支持**(契约:`text` / `image` /
|
||||||
* `documents` 任一项非空即可),本端也照旧只发得出去的那几样,不因为「没写文字」而拦下它。
|
* `documents` 任一项非空即可),本端也照旧只发得出去的那几样,不因为「没写文字」而拦下它。
|
||||||
*/
|
*/
|
||||||
fun shareConfirmCanSend(share: SharedContent, draft: String): Boolean {
|
fun shareConfirmCanSend(share: SharedContent, caption: String): Boolean {
|
||||||
val preview = shareConfirmPreview(share)
|
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 = "发送给昔涟"
|
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
|
@Composable
|
||||||
private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
||||||
@@ -275,7 +317,7 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
|||||||
/** 预览区的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
|
/** 预览区的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
|
||||||
private val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
|
private val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
|
||||||
|
|
||||||
/** 预览里正文给几行(全文在下面那个输入框里)。 */
|
/** 预览里正文给几行(超出的部分收成省略号,见 [ShareConfirmPreviews] 的 KDoc)。 */
|
||||||
private const val SHARE_PREVIEW_TEXT_LINES = 3
|
private const val SHARE_PREVIEW_TEXT_LINES = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,19 +8,30 @@ import top.yeij.cyrene.domain.share.SharedContent
|
|||||||
import top.yeij.cyrene.domain.share.SharedDocument
|
import top.yeij.cyrene.domain.share.SharedDocument
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分享确认面板的三条判据([shareConfirmPreview] / [shareConfirmDraft] / [shareConfirmCanSend])。
|
* 分享确认面板的三条判据([shareConfirmPreview] / [shareConfirmBody] / [shareConfirmCanSend])。
|
||||||
*
|
*
|
||||||
* 背景:主人 2026-10-06 拍板——分享进来的内容不再「一到就发」,要像其他 IM 一样先给一个确认面板。
|
* 背景:主人 2026-10-06 拍板——分享进来的内容不再「一到就发」,要像其他 IM 一样先给一个确认面板。
|
||||||
* 面板本身是 Compose 的东西、只能在真机上看(观感由主人验收),但它**该显示什么**、**输入框从哪
|
* 面板本身是 Compose 的东西、只能在真机上看(观感由主人验收),但它**该显示什么**、**发送时正文
|
||||||
* 个值开始**、**「发送」是否可点**这三件事不该只活在 Composable 里:它们决定了「用户看到的是不是
|
* 是哪几段拼起来的**、**「发送」是否可点**这三件事不该只活在 Composable 里:它们决定了「用户看到
|
||||||
* 他要发的那一份」。本测试钉住四件事:
|
* 的是不是他要发的那一份」。本测试钉住四件事:
|
||||||
*
|
*
|
||||||
* 1. **内容一样都不少**:图片与文件按分享进来的顺序原样进预览(多张图的顺序就是发送顺序);
|
* 1. **内容一样都不少**:图片与文件按分享进来的顺序原样进预览(多张图的顺序就是发送顺序);
|
||||||
* 2. **空的也算没有**:空地址画出来是一个坏掉的缩略图,全空白的正文占着一行什么也不说;
|
* 2. **空的也算没有**:空地址画出来是一个坏掉的缩略图,全空白的正文占着一行什么也不说;
|
||||||
* 3. **输入框的初值就是分享进来的正文本身,一个字不加**——这是红线:往用户正文里拼来源标记
|
* 3. **拼接规则穷举**([shareConfirmBody]):分享正文 + 用户描述,两段都在时**一个空行**分隔、
|
||||||
* (「[用户分享]」之类)等于让用户正文冒充系统指令(`02/F4` / `design/11 §10`);
|
* 只有一段就只用那一段、都空就是空串——**一个字不许丢、也不许加**;
|
||||||
* 4. **「发送」可点 ≙ 真的会有东西发出去**:与 `sendShared` 开头那条早退同一条判据——那边在
|
* 4. **「发送」可点 ≙ 真的会有东西发出去**:与 `sendShared` 开头那条早退同一条判据——那边在
|
||||||
* 「正文空白 + 没有图片 + 没有文件」时原地返回,一次发送都不会发生。
|
* 「正文空白 + 没有图片 + 没有文件」时原地返回,一次发送都不会发生。
|
||||||
|
*
|
||||||
|
* ## W53X3 改掉了什么(读这份测试时要先知道)
|
||||||
|
* 输入框的语义此前被搞反过:一度被**预填成分享进来的正文**,于是面板在真机上把同一句话排两遍
|
||||||
|
* (预览的「分享的文字」块一遍、输入框里又一遍)。主人澄清后更正为:那个框是「**用户附加的描述**」
|
||||||
|
* 输入框,初值**恒为空**,发送时与分享正文**拼接**。所以 [shareConfirmBody] 取代了原来那个
|
||||||
|
* `shareConfirmDraft(share) = 分享正文`(那个函数的名字与行为此后都不对了:它曾经让输入框里那一份
|
||||||
|
* 「才是真正会发出去的文字」,现在真正发出去的是拼出来的那一段)。
|
||||||
|
*
|
||||||
|
* ⚠️ 「输入框初值为空」本身是 Compose 状态(`ChatScreen` 里那格 `remember`),单测碰不到——它由
|
||||||
|
* 主人真机验收(输入框空着、预览里有正文)。这份测试能钉住的是它的**后果**:描述为空时,拼出来的
|
||||||
|
* 正文就是分享正文本身、而且只出现**一次**(见 `the caption is not a copy of the shared text`)。
|
||||||
*/
|
*/
|
||||||
class ShareConfirmTest {
|
class ShareConfirmTest {
|
||||||
|
|
||||||
@@ -70,46 +81,74 @@ class ShareConfirmTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 link = "https://example.com/某个页面?id=1"
|
val empty = SharedContent()
|
||||||
assertEquals(link, shareConfirmDraft(SharedContent(text = link)))
|
|
||||||
|
|
||||||
// 分享里本来没有正文 ⇒ 空手开始(不是一句模板、也不是一个空行)。
|
// ① 两段都有:分享正文 + 一个空行 + 用户描述。分隔符写死成字面量 `"\n\n"`——用常量去断言
|
||||||
assertEquals("", shareConfirmDraft(SharedContent(images = listOf("file:///cache/share-a.png"))))
|
// 常量等于什么都没钉(把这个常量改成空格,测试照样绿)。
|
||||||
|
assertEquals("看这个\n\n这句话不错", shareConfirmBody(shared, "这句话不错"))
|
||||||
|
// ② 只有分享正文:就是那一段,**不补分隔符**(给一段话收个尾是凭空多出来的东西)。
|
||||||
|
assertEquals("看这个", shareConfirmBody(shared, ""))
|
||||||
|
// ③ 只有用户描述:纯图片 / 纯文件的分享就靠这一档带话(此时分享正文是空的)。
|
||||||
|
assertEquals("这句话不错", shareConfirmBody(empty, "这句话不错"))
|
||||||
|
// ④ 都空:空串(能发的只剩图片 / 文件,正文那一栏本来就该空着)。
|
||||||
|
assertEquals("", shareConfirmBody(empty, ""))
|
||||||
|
|
||||||
// 两侧空白归一(与 ShareRules.extract 取正文那一处同一个口径)。
|
// 分隔符本身也钉一下:它只有一种写法,改了这里就是改了「两段」这个语义。
|
||||||
assertEquals("看这个", shareConfirmDraft(SharedContent(text = " 看这个 ")))
|
assertEquals("\n\n", SHARE_CONFIRM_BODY_SEPARATOR)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the draft is exactly what the preview shows`() {
|
fun `neither piece is rewritten, only the whitespace around them goes`() {
|
||||||
// 两边必须同值:预览里那一块说的是「分享进来的是什么」,而输入框的初值就是它的可编辑副本。
|
// 分享正文里用户自己的换行、空行**一个字不动**(只去两侧空白,与 ShareRules.extract 取正文
|
||||||
// 分成两个判据之后若各写各的,窗口里就会出现「预览显示 A、输入框里是 B」这种一眼看去
|
// 同一个口径);用户描述同样只去两侧空白,中间的换行是他打的,不是我们排版的地方。
|
||||||
// 分不清要发哪一份的状态。
|
val multiLine = "第一段\n\n第二段"
|
||||||
listOf(
|
assertEquals(multiLine, shareConfirmBody(SharedContent(text = multiLine), ""))
|
||||||
SharedContent(text = "看这个"),
|
|
||||||
SharedContent(text = " 带空白的正文 "),
|
|
||||||
SharedContent(images = listOf("file:///cache/share-a.png")),
|
|
||||||
SharedContent(),
|
|
||||||
).forEach { share ->
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"预览与输入框的初值必须同源",
|
"$multiLine\n\n我补的\n\n第三行",
|
||||||
shareConfirmPreview(share).text,
|
shareConfirmBody(SharedContent(text = multiLine), " 我补的\n\n第三行 "),
|
||||||
shareConfirmDraft(share),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
// 两侧空白归一:正文里那点空白不该原样进聊天记录(`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
|
@Test
|
||||||
fun `the draft never carries a source marker`() {
|
fun `the caption is not a copy of the shared text`() {
|
||||||
val draft = shareConfirmDraft(SharedContent(text = "看这个"))
|
val link = "https://example.com/某个页面?id=1"
|
||||||
|
val body = shareConfirmBody(SharedContent(text = link), "")
|
||||||
|
|
||||||
|
// 描述是空的(输入框初值恒为空)⇒ 发出去的就是分享正文本身,**而且只出现一次**。
|
||||||
|
// W53X 的第一版把正文预填进输入框(于是它「才是真正会发出去的文字」),面板上就渲染成
|
||||||
|
// 同一句话排两遍——这条断言钉的是那个错法的后果:描述不预填时,正文不会在正文里出现两次。
|
||||||
|
assertEquals(link, body)
|
||||||
|
assertEquals("分享进来的正文只能出现一次", 1, Regex(Regex.escape(link)).findAll(body).count())
|
||||||
|
|
||||||
|
// 描述带字时,链接照样**逐字节**在正文里(不许被截短、不许被改写成别的形态)。
|
||||||
|
val withCaption = shareConfirmBody(SharedContent(text = link), "这个页面我看过了")
|
||||||
|
assertTrue("分享正文必须逐字节保留", withCaption.contains(link))
|
||||||
|
assertTrue(withCaption.endsWith("这个页面我看过了"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the body never carries a source marker`() {
|
||||||
|
val body = shareConfirmBody(SharedContent(text = "看这个"), "补一句")
|
||||||
|
|
||||||
// 来源标识走后端的 RoleSystem 来源消息 + 结构化 SenderName,人类可见的标记只能放在本端
|
// 来源标识走后端的 RoleSystem 来源消息 + 结构化 SenderName,人类可见的标记只能放在本端
|
||||||
// 渲染层——把它拼进正文 = 让用户正文冒充系统指令,项目红线。
|
// 渲染层——把它拼进正文 = 让用户正文冒充系统指令,项目红线。拼接这一层是最容易顺手加上
|
||||||
assertEquals("看这个", draft)
|
// 「[分享]」的地方,所以这条也在这里钉住。
|
||||||
assertFalse(draft.contains("分享"))
|
assertFalse(body.contains("[用户"))
|
||||||
assertFalse(draft.contains("[用户"))
|
assertFalse(body.contains("分享"))
|
||||||
|
assertEquals("看这个\n\n补一句", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -119,30 +158,59 @@ class ShareConfirmTest {
|
|||||||
val image = SharedContent(images = listOf("file:///cache/share-a.png"))
|
val image = SharedContent(images = listOf("file:///cache/share-a.png"))
|
||||||
val document = SharedContent(documents = listOf(SharedDocument(uri = "file:///cache/share-0.txt")))
|
val document = SharedContent(documents = listOf(SharedDocument(uri = "file:///cache/share-0.txt")))
|
||||||
|
|
||||||
// 用户改过的正文也算(他把分享的链接改短、或者换上自己想说的话)。
|
// 分享带正文、用户没补话:正文本来就是这次要发的东西,照样可发。
|
||||||
assertTrue(shareConfirmCanSend(text, "改过的正文"))
|
assertTrue(shareConfirmCanSend(text, ""))
|
||||||
|
// 纯文字描述也行(没有可预览的内容时,用户补的那一句就是全部内容)。
|
||||||
assertTrue(shareConfirmCanSend(nothing, "只写一句话也行"))
|
assertTrue(shareConfirmCanSend(nothing, "只写一句话也行"))
|
||||||
|
assertTrue(shareConfirmCanSend(text, "再多说一句"))
|
||||||
// 纯图片 / 纯文件照样可发:服务端支持只带 image / documents 的回合,本端不因为「没写文字」拦它。
|
// 纯图片 / 纯文件照样可发:服务端支持只带 image / documents 的回合,本端不因为「没写文字」拦它。
|
||||||
assertTrue(shareConfirmCanSend(image, ""))
|
assertTrue(shareConfirmCanSend(image, ""))
|
||||||
assertTrue(shareConfirmCanSend(document, ""))
|
assertTrue(shareConfirmCanSend(document, ""))
|
||||||
assertTrue(shareConfirmCanSend(image, " "))
|
assertTrue(shareConfirmCanSend(image, " "))
|
||||||
// 把正文整段删光,图片与文件照样发得出去:用户反悔的只是那段文字,不是这次分享的其余部分。
|
// 把描述留空也不影响图片与文件:用户只是没写话,不是反悔了这次分享。
|
||||||
val textAndImage = SharedContent(text = "看这个", images = listOf("file:///cache/share-a.png"))
|
val textAndImage = SharedContent(text = "看这个", images = listOf("file:///cache/share-a.png"))
|
||||||
assertTrue(shareConfirmCanSend(textAndImage, ""))
|
assertTrue(shareConfirmCanSend(textAndImage, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `send is not offered when nothing at all would go out`() {
|
fun `send is not offered when nothing at all would go out`() {
|
||||||
// 这条与 `sendShared` 开头那条早退**同一条**:正文(去空白后)为空、图片为空、文件也为空时
|
// 这条与 `sendShared` 开头那条早退**同一条**:正文([shareConfirmBody] 拼出来的那一段)
|
||||||
// 那边原地 `return`——一次发送都不会发生。键若画成可点的,用户按下去只看到面板关掉、
|
// 为空、图片为空、文件也为空时,那边原地 `return`——一次发送都不会发生。键若画成可点的,
|
||||||
// 什么都没发生,而他会以为自己少发了一条。
|
// 用户按下去只看到面板关掉、什么都没发生,而他会以为自己少发了一条。
|
||||||
assertFalse(shareConfirmCanSend(SharedContent(), ""))
|
assertFalse(shareConfirmCanSend(SharedContent(), ""))
|
||||||
assertFalse(shareConfirmCanSend(SharedContent(), " "))
|
assertFalse(shareConfirmCanSend(SharedContent(), " "))
|
||||||
assertFalse(shareConfirmCanSend(SharedContent(text = "看这个"), " "))
|
assertFalse(shareConfirmCanSend(SharedContent(text = " "), "\n"))
|
||||||
// 正文被删光的**纯文本分享**(一个链接分享就是这一档)就什么都不剩了——此时没有可发的
|
|
||||||
// 东西,而这正是「发送」该变灰的那种情形。
|
|
||||||
assertFalse(shareConfirmCanSend(SharedContent(text = "看这个"), ""))
|
|
||||||
// 只剩「丢件计数」的分享(有 2 张图读不到)同样没有可发的东西。
|
// 只剩「丢件计数」的分享(有 2 张图读不到)同样没有可发的东西。
|
||||||
assertFalse(shareConfirmCanSend(SharedContent(failedImages = 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