W72 纯附件消息也有气泡外侧时间

时间原本挂在「这条消息**最后一个有正文的气泡**」外侧(W69),而纯附件消息(`documents`
或 `image` 非空、`content` 为空)解析出来的那个占位气泡 `body` 也是空,`Bubble` 见空
`body` 什么都不画 ⇒ 那条时间**整条没被画出来**(主人 2026-10-07 验收 W69 时发现的缺口)。

- 判据抽成纯函数 `messageTimeCarrier(message, bubbles)`:先找最后一个有正文的气泡(W69
  原本那一档,一个字都不改);一个都没有时按「谁最后画」往回退(语音形态 → 图片 →
  附件芯片排),落点用 `MessageTimeCarrier` 表示。真的空气泡(`isContentlessUserMessage`)
  仍由 `ContentlessNote` 那条路自己画时间,与 W69 一致,未改。
- 附件芯片排 / 图片 / 语音形态三块改为经 `BubbleWithTime` 渲染:**不是落点时 `time = null`,
  连 `Box` 都不建** ⇒ 与 W69 之前逐像素相同。贴哪一侧、宽度预扣(时间不参与 weight)、
  底对齐全部复用 W69 那一个组件,没有第二份布局逻辑。
- 新增 `MessageTimeCarrierTest` 11 条:纯附件 / 纯图片 / 无转写语音 / 角色无关 /
  多块时的优先序 / 有正文时 W69 口径不变 / 真空消息返回 null;`MessageTimeLabelTest`
  16 条一条未动。
- 假后端补夹具:`/__control/messages` 的 `text=`(**空串** = 这条没有正文)与 `documents=`
  (`extracted` / `listed` / `unreadable`)。此前「纯附件消息」在本机**根本造不出来**
  ——工具不实现 upload 摄取链路,`PATCH` 又要求 `content` 非空——设备验收因此无从做起。
  README 补 §8.3 与端点表。

验收:`assembleDebug` 通过;`testDebugUnitTest` **129 suites / 1636 tests / 0 failures /
0 errors**(基线 128 / 1625)。
This commit is contained in:
AskaEth committed 2026-10-07 19:16:25 +08:00
1 parent f1e894e71f
commit a94e310cd7
4 files changed
+436 -27

No files matched your search

+52 -1
View File
@@ -223,6 +223,49 @@ def image_fixture(path: str) -> str:
return "data:{};base64,{}".format(mime, base64.b64encode(raw).decode("ascii"))
# —— 附件夹具(`documents` DTO 键)———————————————————————————————
#
# 为什么要造它:**纯附件消息**(`documents` 非空、`content` 为空)是「气泡外侧那枚时间」新补的那一档,
# 而它在库里**没有别的造法** —— 本工具不实现 upload / 摄取链路(见 README §11),
# `PATCH /api/v1/messages/{id}` 又要求 `content` 非空 ⇒ 一条这样的消息以前根本灌不出来。
#
# 形状按客户端解出来的写(`HistoryMessageDto.documents` → `DocumentRefDto.kt:67`)。与真后端的
# 一致处:**空 / 缺失就整键省略**(后端 `omitempty`),客户端把那两种形状都归一成空列表。
DOCUMENT_FIXTURE_NAME = "6c1f0a2b3d4e5f60718293a4b5c6d7e8.txt"
DOCUMENT_FIXTURE_REASON = "这个夹具不实现真正的摄取,所以没有可注入的正文"
def documents_fixture(kind: str):
"""按控制端点给的种类造一个 `documents` 键的值;`none` ⇒ 不打这个键(返回 None)。
`kind` 三档对应芯片上看得见的那三种样子(文案出处 `DocumentChips.statusLabel`):
| kind | `status` | 设备上那枚芯片 |
|---|---|---|
| `extracted` | `extracted` | 常规色 · `TXT · 12 B · 10 字 · 已读入` |
| `listed` | `listed` | 常规色 · `… · 只看得到清单`(正文没进上下文,**不是**读不到) |
| `unreadable` | `unreadable` | 灰态 · `… · 读不到`,`reason` 那句原话留给预览弹层 |
只给**一枚**芯片:造这一档要验的是「有附件、没有正文时那枚时间挂在哪一块」,不是芯片排的换行。
"""
if kind in (None, "", "none"):
return None
if kind not in ("extracted", "listed", "unreadable"):
raise KeyError(kind)
payload = {
"name": DOCUMENT_FIXTURE_NAME,
"mime": "text/plain",
"size": 12,
"status": kind,
}
if kind == "extracted":
payload["chars"] = 10
if kind == "unreadable":
payload["reason"] = DOCUMENT_FIXTURE_REASON
return [payload]
def iso_now() -> str:
return datetime.now().astimezone().isoformat(timespec="milliseconds")
@@ -1564,12 +1607,19 @@ class Handler(BaseHTTPRequestHandler):
except (OSError, ValueError) as error:
self._fail(400, "bad_request", "image_file unusable: {}".format(error))
return
try:
documents = documents_fixture(self._param(query, "documents", None))
except KeyError:
self._fail(400, "bad_request", "documents must be one of extracted/listed/unreadable/none")
return
if session_id not in self.store.sessions:
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
return
first_id = last_id = None
for index in range(1, count + 1):
content = template.format(n=index) if template else "{} #{}".format(prefix, index)
# `text=` **空串**与「不带 text」是两件事:前者是「就要一条没有正文的消息」
# (纯附件 / 纯图 / 无转写的语音都靠它造),后者走原来的 `seed #N` 默认正文。
content = template.format(n=index) if template is not None else "{} #{}".format(prefix, index)
message = self.store.add_message(
session_id,
role,
@@ -1577,6 +1627,7 @@ class Handler(BaseHTTPRequestHandler):
platform=platform,
voice=voice,
image=image,
documents=documents,
)
first_id = first_id or message["id"]
last_id = message["id"]