feat(W69): 气泡外侧显示消息时间 —— 主人贴左 / 昔涟贴右的灰色小字(今天 HH:mm、昨天、今年 M月D日 HH:mm、跨年 YYYY年M月D日)

主人:「用户消息气泡左侧和他的消息气泡右侧还一点点留空的空间 能在这两份区域放一个灰色小字
显示消息发送时间吗?跨天和恒久远的消息也要标明简单日期。」——时间放在气泡**外侧那块空白**里,
不进气泡内部,也不进下方那行角标。

一、文案是纯函数 `messageTimeLabel(createdAt, nowMillis, zone)`
   四档按**本地日历日**分(不是「相差不到 24 小时」):今天 `14:30` / 昨天 `昨天 09:05` /
   今年内更早 `3月7日 09:05`(月日不补零、时分补零)/ 跨年 `2025年12月31日`(**不带时分**:
   一年前的消息精确到分没有意义)。用 `java.time` + `Locale.ROOT`,无新依赖、无新表驱动项。

二、两条口径(都写进 KDoc 里)
   1. **未来时间走「今天」那一档**(判据写成 `!day.isBefore(today)`):服务端时钟略快 / 本机略慢
      时出现的时间戳没有别的语义,只显示 `HH:mm`;不造「明天」这个词,也不摆一个用户从没见过的
      假日期。整整偏一天 / 一年也一起吃下。
   2. **`createdAt <= 0` ⇒ 一个字都不画**(返回 `null`):0 / 负数只可能来自「这个键根本没传过来」
      或解析事故,画成 `1970年1月1日` / `--:--` 都是替服务端编一个它没说过的答案。
      本地降级气泡(`proactive-local:`)**有**真时间(`proactiveIngestFor` 缺席时补 `nowMillis`)
      ⇒ 照常显示;流式气泡与待发气泡本端没有时间戳 ⇒ 走这一档,与加这一块之前逐像素相同。

三、布局:`BubbleWithTime`(新,`MessageBubble.kt`)
   时间与气泡**同一行**(`Box` + 底对齐),外侧用 `padding` 把时间那一条的宽度**先扣掉**,
   气泡吃剩下的全部宽度。取舍与理由:
   - 时间**不参与 weight 分配**,而是先用 `rememberTextMeasurer` 按它将要用的那个
     `MiuixTheme.textStyles.footnote2` 量出**精确宽度**(`Text` 内部只是在同一份 style 上 merge
     显式参数 ⇒ 量出来的就是排出来的那一串字),所以它既不会溢出屏幕、也不会被压成两行;
   - 气泡可用宽度 = `min(300.dp, 屏宽 - 两边 14dp - 时间条)`:长消息照旧用满能用的宽度,
     **代价写进 KDoc**——有时间的消息气泡最多窄一条时间条(这是「与气泡同行」的必然结果);
     只有「极窄屏 + 超大字号」才会明显变窄,那时选择如实把日期显示完整(截断 `2025年12月31日`
     比窄一点的气泡更亏);
   - 不用「让时间先占住宽度」那种零测量写法:`Row` 给非 weight 子节点的宽度是**按子节点顺序**
     递减的,「气泡在前」的那一侧必然先把宽度吃光、把时间挤成一条竖线;
   - 时间在 `clip` / `background` / `combinedClickable` 之外 ⇒ 气泡的样子、选字范围、长按点击区
     一个字都没变;气泡自己那一侧离屏幕的边距与加这一块之前逐像素相同;
   - 时间挂在这条消息**最后一个有正文的气泡**上(解析出的末段偶尔只有旁白,那一项不画正文框);
     `ContentlessNote`(空气泡位置那句如实说明)同样挂,走同一条路。

四、未动的部分:下方那行角标(来源 / 六态 / 分享 / 「⋯」)语义一个字没改,只是**并列的另一块**;
   消息内容 / 排序 / 分页 / 去重 / 「跟随底部」的触发键一概没碰;`MessageGroup` 与
   `StreamingBubble` / `PendingBubble` 的既有调用一个像素都没变。

验收:`assembleDebug` 通过(APK 47,278,290 B);
`testDebugUnitTest` 全量 127 suites / 1580 tests / 0 failures / 0 errors / 0 skipped
(基线 126 / 1564 → 新增 `MessageTimeLabelTest` 16 条)。
单测覆盖每一档的两侧:同一天 / 正好午夜 / `23:59` 与次日 `00:01`(只差两分钟但跨天)/
昨天跨月 / 昨天跨年(「昨天」优先于跨年档)/ 今年内更早(月日不补零、一周前仍是这一档)/
跨年(前天、以及整整一年前同一个 `8月14日`)/ 闰年 `2/29`(当年是昨天、次年整日)/
未来(同天 + 几分钟、次日、次年)/ `0` / `-1` / `Long.MIN_VALUE` / 同一毫秒在上海与 UTC 分属两档。
`D` 认成「一年里的第几天」把 `8月14日` 排成 `8月226日`——这条正是被这批单测当场拦下的,已写进
`MessageDayFormat` 的 KDoc。
This commit is contained in:
AskaEth committed 2026-10-07 18:57:14 +08:00
1 parent 11602c0138
commit 7b39994715
2 files changed
+396 -12

No files matched your search

@@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
@@ -33,6 +34,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
@@ -61,6 +63,10 @@ import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.More
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
/** 流式进行中的光标。 */
private const val CURSOR = "▍"
@@ -74,6 +80,11 @@ private val MESSAGE_ACTION_ICON_SIZE = 18.dp
/** 「⋯」与来源角标之间的间距。 */
private val MESSAGE_ACTION_GAP = 4.dp
/**
* 气泡外侧那枚时间与气泡之间的空隙:时间贴气泡,但不贴成一体(也不必去够屏幕边)。
*/
private val MESSAGE_TIME_GAP = 6.dp
/** 「⋯」的读屏名字,同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
private const val MESSAGE_ACTION_DESCRIPTION = "消息操作"
@@ -129,6 +140,16 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
* ([ContentlessNote])——空气泡是最糟的一种呈现:用户完全不知道发生了什么。
*
* ## 外侧那枚时间(W69)
* 每条**落库**消息在气泡外侧再画一枚灰色小字(主人:用户的气泡靠右 ⇒ 时间贴气泡左边;她的气泡
* 靠左 ⇒ 时间贴气泡右边):文案由纯函数 [messageTimeLabel] 给(今天 `14:30` / `昨天 09:05` /
* `3月7日 09:05` / 跨年 `2025年12月31日`),布局与宽度取舍见 [BubbleWithTime]。
* 它是与气泡下方那行角标(来源 / 六态 / 「⋯」)**并列的另一块**:那块说的是「这条从哪来、能对它
* 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。
*
* 只有落库消息有它:流式气泡([StreamingBubble])与待发气泡([PendingBubble],[PendingMessage]
* 本端就没有时间戳这个概念)走的都是「不显示」那一档。
*
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
*
@@ -195,6 +216,18 @@ fun MessageBubble(
// 所以 ③ 排队中**不会**被渲染成灰态——规范 §1.3 特意点名不要合并这两态。
val gray = phase == TurnPhase.Answering
// 气泡**外侧**那枚时间(W69)。它在组合期按当下时刻算一次,**不 remember**:文案是
// (时间戳, 现在, 时区) 的函数,而「现在」只在重组时才有机会更新——冻结它就等于让跨天之后
// 还挂着昨天的口径。代价是「页面开着不动、跨度又恰好跨过零点」时它不会自己变,
// 不为这一件事加一个 ticker(见 [messageTimeLabel])。
val outsideTime = messageTimeLabel(
createdAt = message.createdAt,
nowMillis = System.currentTimeMillis(),
)
// 时间挂在**最后一个有正文的气泡**上(而不是「列表最后一项」):解析出的末段偶尔只有旁白
// (`<action>`/`<narration>`),那一项不画正文框,挂上去时间就没有落脚点。
val timeCarrier = bubbles.indexOfLast { it.body.isNotEmpty() }
MessageGroup(
isUser = isUser,
footer = bubbleFooter(
@@ -228,7 +261,9 @@ fun MessageBubble(
}
if (isContentlessUserMessage(message)) {
// 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。
ContentlessNote(onLongPress = openMenu)
BubbleWithTime(time = outsideTime, isUser = isUser) { side ->
ContentlessNote(onLongPress = openMenu, modifier = side)
}
} else {
bubbles.forEachIndexed { index, bubble ->
Bubble(
@@ -238,6 +273,9 @@ fun MessageBubble(
showCursor = gray,
onLongPress = openMenu,
gray = gray,
// 时间挂在**最后一个有正文的气泡**外侧(`-1` = 这条一个都没有,不可能发生,
// 但真发生也只是不画时间,不会把时间画到空气泡旁边去)。
sideTime = if (index == timeCarrier) outsideTime else null,
)
}
}
@@ -340,6 +378,76 @@ fun bubbleFooter(
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
/**
* 一条消息**外侧**那枚灰色时间的文案(纯函数,可单测);`null` = 这条一个字都不画。
*
* 四档,全部按**本地日历日**分(不是「相差不到 24 小时」那种算法:`23:59` 与次日 `00:01` 只差
* 两分钟,却已经跨天,这一档必须是「昨天」):
* - **今天**(与 `nowMillis` 落在同一个本地日期)→ `14:30`;
* - **昨天** → `昨天 09:05`;
* - **今年内更早** → `3月7日 09:05`(月日不补零,时分补零);
* - **跨年** → `2025年12月31日`。**刻意不带时分**:一年前的消息精确到分没有意义,年月日已经
* 把「多久远」说清了,多四个字符反而更难扫。
*
* ## 未来时间:按「今天」那一档显示(不造「明天」这个词)
* 服务端时钟略快 / 本机时钟略慢时会出现几秒到几分钟「之后」的时间戳,它没有任何要表达的语义
* (这条就是刚刚到的),所以只显示 `HH:mm`——那正是这条消息发生的那一刻。判据因此写成
* `!day.isBefore(today)` 而不是 `day == today`:顺手也把「服务端时钟整整偏了一天 / 一年」这种
* 坏时钟吃掉——宁可只显示一个时刻,也不在用户眼皮底下编出一个他从没见过的「明天」或假日期。
*
* ## 非法 / 缺失(`<= 0`):整条不画
* 这个字段是**毫秒 Unix 时间戳**;`0` / 负数只可能来自「这个键根本没传过来」或解析事故,不是
* 一个真实时刻。画成 `1970年1月1日` 或 `--:--` 都是在替服务端编一个它没说过的答案;而一条没有
* 时间的消息并不缺什么(正文、来源角标、动作入口都还在,见 [MessageBubble])。
*
* ## 为什么在 UI 层算,而不是让 VM 把文案算好塞进 item
* 它只依赖(这条的时间戳,现在,时区)——前两个 UI 层本地就有。为了一个「现在」去动 item 模型与
* 列表的 key 不划算,而且那样还会把文案**冻住**:同一秒里算出来的字符串被 diff 当成同一个 item,
* 跨天之后那一屏就永远停在旧口径。
*
* `zone` 可注入(默认本机时区),`nowMillis` 由调用方喂:这条映射自己不读表,所以可单测、可复现
* (与 [searchHitTime] 同一条写法)。
*/
fun messageTimeLabel(
createdAt: Long,
nowMillis: Long,
zone: ZoneId = ZoneId.systemDefault(),
): String? {
if (createdAt <= 0L) return null
val at = Instant.ofEpochMilli(createdAt).atZone(zone)
val day = at.toLocalDate()
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
return when {
// 今天**与未来**同一档(见 KDoc:未来只可能是时钟漂移,不造「明天」这个词)。
!day.isBefore(today) -> at.format(MessageClockFormat)
day == today.minusDays(1) -> "$YESTERDAY_PREFIX ${at.format(MessageClockFormat)}"
day.year == today.year -> at.format(MessageDayFormat)
else -> at.format(MessageDateOnlyFormat)
}
}
/**
* 「今天」那一档,同时也是其余各档拼时分的零件:24 小时制、补零。
*
* `Locale.ROOT`:刻意不跟随设备 `locale`——这几档文案的形状是固定的(「3月7日」不会因为系统语言
* 变成别的写法),不给本地化留口子。
*/
private val MessageClockFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)
/**
* 「今年内更早」那一档:`3月7日 09:05`(月日**不**补零,与 [messageTimeLabel] 的例一致)。
*
* 两处模式字母都要盯住:**`d`** 才是「月里的第几天」,大写 **`D`** 是「一年里的第几天」
* (`M'月'D'日'` 会把 8 月 14 日排成「8月226日」——W69 的单测就是在这一步拦下的)。
*/
private val MessageDayFormat = DateTimeFormatter.ofPattern("M'月'd'日' HH:mm", Locale.ROOT)
/** 「跨年」那一档:`2025年12月31日`(刻意不带时分)。 */
private val MessageDateOnlyFormat = DateTimeFormatter.ofPattern("yyyy'年'M'月'd'日'", Locale.ROOT)
/** 「昨天」那两个字:只与时分连用(`昨天 09:05`),不会单独成句。 */
private const val YESTERDAY_PREFIX = "昨天"
/**
* 乐观发送中的用户消息(还没落库)。
*
@@ -581,6 +689,87 @@ private fun MessageGroup(
}
}
/**
* 气泡本体 + 它**外侧**那一枚灰色时间(主人 W69):**用户**的气泡靠右 ⇒ 时间贴气泡**左边**;
* **她**的气泡靠左 ⇒ 时间贴气泡**右边**。
*
* ## 时间那一条的宽度是「量」出来的,不参与宽度分配
* 气泡可用宽度有限([BubbleBody] 自己封了 `300.dp` 的上限),而时间与气泡**同一行**,必然分掉
* 一点横向空间。这里**不让两者去抢 weight**,而是先用 [rememberTextMeasurer] 把这一行字按
* **它将要用的那个 [MiuixTheme.textStyles.footnote2]** 量一遍,拿到它排出来的**精确宽度**,
* 再用 `padding` 把这条宽度从气泡的可用宽度里**先扣掉**:
* - 时间量得准(同一份 `TextStyle`、同一份字体缩放与字体回退,量出来的就是排出来的那一串字——
* `Text` 内部只是在它自己那份 `style` 上 merge 显式参数,不参数字段一律不动),所以它**既不会
* 溢出屏幕、也不会被压成两行**;
* - 气泡吃**剩下的全部**宽度(`padding` 是在约束里扣的,气泡自己的 `widthIn(max = 300.dp)` 照旧
* 生效 ⇒ 实际可用宽度 = `min(300.dp, 屏宽 - 两边 14dp - 时间条)`),所以长消息照旧用满能用的宽度。
* 代价写在明处:**有时间的消息,气泡比加这一块之前最多窄一条时间的宽度**(这是「时间与气泡同行」
* 的必然结果,也是主人要的那件事)。只有「极窄屏 + 超大字号」这一条极端组合下气泡才会明显变窄
* ——那时我们选择**如实把日期显示完整**:截断「2025年12月31日」比窄一点的气泡更亏。
*
* 为什么不用「把时间排在子节点前面、让它先占住宽度」这种零测量的写法:`Row` 给非 weight 子节点的
* 宽度是**按子节点顺序**递减的,所以「气泡在前」的那一侧(她那一侧)必然先把宽度吃光、把时间挤成
* 一条竖线;而 Compose 的 `Row` 只有「顺序摆放」一种排布,做不到「先量、后摆」。自写 `Layout` 当然
* 可以,但要自己接管窄屏下的钳制,而且那份宽度就是同一笔账——量一次是这里最小的一步。
*
* ## 摆在哪(三条都是「不许动气泡」的具体落实)
* - 时间在**气泡外边**:`padding` 在 `clip` / `background` 之外,所以它不是气泡的一部分——底色、
* 圆角、[SelectionContainer] 的选字范围、长按的点击区全都与加它之前逐像素 / 逐行为相同;
* - 时间与气泡**底对齐**:贴着气泡最后一行(气泡很高时它也不会跑到中间去);
* - 与气泡之间有 [MESSAGE_TIME_GAP] 的空隙,免得那串灰字和气泡贴成一体。
*
* 整块(时间 + 气泡)仍由 [MessageGroup] 那一列按角色对齐(主人靠右、她靠左),所以**气泡自己那
* 一侧**离屏幕的边距与没有时间时**逐像素相同**——时间填的正是原本空着的那条区域。
*
* @param time `null` = 这条消息不显示时间(时间戳缺失 / 非法,见 [messageTimeLabel]):此时**连
* `Box` 都不建**,整个气泡与加这一块之前逐像素相同——流式气泡与待发气泡走的正是这一档
* (后者本端还没有任何时间戳)。
*/
@Composable
private fun BubbleWithTime(
time: String?,
isUser: Boolean,
bubble: @Composable (Modifier) -> Unit,
) {
if (time == null) {
bubble(Modifier)
return
}
val measurer = rememberTextMeasurer()
val style = MiuixTheme.textStyles.footnote2
val density = LocalDensity.current
val strip = remember(measurer, density, time, style) {
with(density) {
measurer.measure(text = time, style = style, maxLines = 1, softWrap = false).size.width.toDp()
} + MESSAGE_TIME_GAP
}
Box {
bubble(
Modifier
.then(if (isUser) Modifier.padding(start = strip) else Modifier.padding(end = strip))
// 气泡贴回它自己那一侧:`strip` 已经吃掉了可用的余量,这里的对齐在正常宽度下是
// 恒等的(气泡就是最宽的那个孩子),留着是为了「万一条比气泡还宽」时也仍在原位。
.align(if (isUser) Alignment.BottomEnd else Alignment.BottomStart),
)
Text(
text = time,
style = style,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
// 宽度已经量过、条也留够了,正常情况下一行必放得下;这两个参数是防「量完到排完之间
// 密度变了」这类不该发生的事——真发生也只是这串字自己被截,不会去挤气泡。
maxLines = 1,
softWrap = false,
modifier = Modifier.align(if (isUser) Alignment.BottomStart else Alignment.BottomEnd),
)
}
}
/**
* 一条消息在气泡层渲染成一个 / 多个气泡。
*
* [sideTime] 只由**携带时间的那个气泡**(最后一个有正文的,见 [MessageBubble])收到,
* 其余气泡与旁白行都不带——时间挂在这条消息上一次,不跟着每个气泡重复。
*/
@Composable
private fun Bubble(
bubble: ParsedBubble,
@@ -589,19 +778,23 @@ private fun Bubble(
showCursor: Boolean = false,
onLongPress: (() -> Unit)? = null,
gray: Boolean = false,
sideTime: String? = null,
) {
AnnotationLine(bubble.head)
val withCursor = showCursor && isLast && bubble.body.isNotEmpty()
val body = if (withCursor) bubble.body + CURSOR else bubble.body
if (body.isNotEmpty()) {
BubbleBody(
text = body,
runs = bubble.runs,
isUser = isUser,
onLongPress = onLongPress,
gray = gray,
trailingCursor = withCursor,
)
BubbleWithTime(time = sideTime, isUser = isUser) { side ->
BubbleBody(
text = body,
runs = bubble.runs,
isUser = isUser,
onLongPress = onLongPress,
gray = gray,
trailingCursor = withCursor,
modifier = side,
)
}
}
AnnotationLine(bubble.tail)
}
@@ -667,11 +860,14 @@ private fun AnnotationLine(parts: List<ContentPart>) {
* ## 长按仍然开动作菜单
* 这条消息在服务端是真实存在的一行,用户能做的事里最相关的一件就是**删掉它**(内容永远到不了了)。
* 气泡没有正文可以让长按去选字,所以长按在这里没有歧义,直接接 [MessageBubble] 的那一个入口。
*
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`:
* 和 [BubbleBody] 一样,它是**气泡外边**的留白,不参与这张灰底方块的绘制与点击区。
*/
@Composable
private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
private fun ContentlessNote(onLongPress: (() -> Unit)? = null, modifier: Modifier = Modifier) {
Box(
modifier = Modifier
modifier = modifier
.widthIn(max = 300.dp)
.clip(BubbleShape)
.background(MiuixTheme.colorScheme.surfaceContainer)
@@ -712,6 +908,10 @@ private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
*
* [trailingCursor] 说的是「`text` 末尾那枚光标」。光标必须**在片段之外**补上去:它不是正文,
* 不该继承她最后那一段的粗体/等宽底色。
*
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`。
* 它排在这条链的**最前面**,所以那一层留白在 `clip` / `background` / `combinedClickable` 之外:
* 气泡自己的样子、选字范围、长按的点击区都还是原来的那一块。
*/
@Composable
private fun BubbleBody(
@@ -721,6 +921,7 @@ private fun BubbleBody(
onLongPress: (() -> Unit)? = null,
gray: Boolean = false,
trailingCursor: Boolean = false,
modifier: Modifier = Modifier,
) {
val contentColor = when {
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
@@ -728,7 +929,7 @@ private fun BubbleBody(
else -> MiuixTheme.colorScheme.onSurfaceContainer
}
Box(
modifier = Modifier
modifier = modifier
.widthIn(max = 300.dp)
// clip + background 与原来那句 `background(color, shape)` 画出来完全一样,
// 多出来的这一层是为了让长按的涟漪被圆角裁住(否则它会溢出到方角外)。
@@ -0,0 +1,183 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.ZoneId
import java.time.ZonedDateTime
/**
* 气泡外侧那枚时间([messageTimeLabel])的回归钉子。
*
* 四档文案错了不会有异常、也不会有编译错误——只是屏幕上写着一个错的日子,而且最容易错的恰恰是
* 边界(`23:59` 与次日 `00:01`、12 月 31 日与 1 月 1 日、闰年 2 月 29 日)。所以这里把每一档的
* 两侧都钉住:判据按**本地日历日**分,不是「相差不到 24 小时」。
*
* 时区与「现在」都由测试喂(`zone` 注入、`nowMillis` 传参),所以这些断言与跑测试的机器无关。
*/
class MessageTimeLabelTest {
private val shanghai = ZoneId.of("Asia/Shanghai")
private val utc = ZoneId.of("UTC")
/** 本地 `yyyy-MM-dd HH:mm` → 毫秒:测试里只写看得懂的时刻,不摆魔数。 */
private fun at(
year: Int,
month: Int,
day: Int,
hour: Int,
minute: Int,
zone: ZoneId = shanghai,
): Long = ZonedDateTime.of(year, month, day, hour, minute, 0, 0, zone).toInstant().toEpochMilli()
// ---------------------------------------------------------------- 今天:只给时分
@Test
fun `same day renders hour and minute`() {
val now = at(2025, 8, 14, 20, 0)
assertEquals("14:30", messageTimeLabel(at(2025, 8, 14, 14, 30), now, shanghai))
}
@Test
fun `exactly midnight is still today`() {
val now = at(2025, 8, 14, 9, 0)
assertEquals("00:00", messageTimeLabel(at(2025, 8, 14, 0, 0), now, shanghai))
assertEquals("23:59", messageTimeLabel(at(2025, 8, 14, 23, 59), at(2025, 8, 14, 23, 59), shanghai))
}
@Test
fun `one digit hours and minutes stay zero padded`() {
val now = at(2025, 8, 14, 12, 0)
assertEquals("09:05", messageTimeLabel(at(2025, 8, 14, 9, 5), now, shanghai))
}
// ---------------------------------------------------------------- 昨天:跨天不由时长决定
@Test
fun `two minutes across midnight flips to yesterday`() {
val now = at(2025, 8, 15, 0, 1)
assertEquals("昨天 23:59", messageTimeLabel(at(2025, 8, 14, 23, 59), now, shanghai))
assertEquals("00:01", messageTimeLabel(at(2025, 8, 15, 0, 1), now, shanghai))
}
@Test
fun `yesterday keeps the time`() {
val now = at(2025, 8, 14, 10, 0)
assertEquals("昨天 09:05", messageTimeLabel(at(2025, 8, 13, 9, 5), now, shanghai))
}
@Test
fun `yesterday across a month boundary`() {
val now = at(2025, 3, 1, 10, 0)
// 2025-02-28 → 2025-03-01 是**昨天**(不是「2月28日」那档),月份少一天 / 多一天都不影响。
assertEquals("昨天 23:30", messageTimeLabel(at(2025, 2, 28, 23, 30), now, shanghai))
}
@Test
fun `yesterday across a year boundary`() {
val now = at(2026, 1, 1, 0, 1)
// 12-31 与 01-01 只差两分钟,「昨天」这一档**优先于**跨年那一档。
assertEquals("昨天 23:59", messageTimeLabel(at(2025, 12, 31, 23, 59), now, shanghai))
assertEquals("昨天 09:00", messageTimeLabel(at(2025, 12, 31, 9, 0), at(2026, 1, 1, 0, 30), shanghai))
}
// ---------------------------------------------------------------- 今年内更早:月日 + 时分
@Test
fun `earlier this year shows month day and time`() {
val now = at(2025, 8, 14, 10, 0)
assertEquals("3月7日 09:05", messageTimeLabel(at(2025, 3, 7, 9, 5), now, shanghai))
}
@Test
fun `month and day are not zero padded`() {
val now = at(2025, 12, 31, 23, 59)
assertEquals("1月1日 00:00", messageTimeLabel(at(2025, 1, 1, 0, 0), now, shanghai))
}
@Test
fun `a week ago stays in the month day branch`() {
val now = at(2025, 8, 14, 10, 0)
// 没有「星期几」这一档:七天前与半年后走的是同一档。
assertEquals("8月7日 09:05", messageTimeLabel(at(2025, 8, 7, 9, 5), now, shanghai))
}
// ---------------------------------------------------------------- 跨年:只给年月日
@Test
fun `crossing the year boundary drops the clock`() {
val now = at(2026, 1, 1, 0, 1)
assertEquals("2025年12月30日", messageTimeLabel(at(2025, 12, 30, 23, 59), now, shanghai))
}
@Test
fun `the year decides the branch not the elapsed time`() {
val now = at(2026, 8, 14, 10, 0)
// 整整一年前、同一个「8月14日」:仍然是跨年那一档(不带时分)。
assertEquals("2025年8月14日", messageTimeLabel(at(2025, 8, 14, 10, 0), now, shanghai))
}
// ---------------------------------------------------------------- 闰年
@Test
fun `leap day is yesterday in a leap year and a full date afterwards`() {
assertEquals(
"昨天 23:59",
messageTimeLabel(at(2024, 2, 29, 23, 59), at(2024, 3, 1, 0, 0), shanghai),
)
assertEquals(
"2024年2月29日",
messageTimeLabel(at(2024, 2, 29, 12, 0), at(2025, 3, 1, 12, 0), shanghai),
)
}
// ---------------------------------------------------------------- 未来时间(时钟漂移)
@Test
fun `future timestamps fall back to the today branch`() {
val now = at(2025, 8, 14, 14, 30)
// 同一刻前几分钟:服务端时钟略快 / 本机略慢,就是个普通时刻。
assertEquals("14:35", messageTimeLabel(at(2025, 8, 14, 14, 35), now, shanghai))
// 整整一天 / 一年之后也一样:不造「明天」这个词,也不摆一个假日期(见 KDoc)。
assertEquals("09:00", messageTimeLabel(at(2025, 8, 15, 9, 0), now, shanghai))
assertEquals("00:00", messageTimeLabel(at(2026, 1, 1, 0, 0), now, shanghai))
}
// ---------------------------------------------------------------- 缺失 / 非法
@Test
fun `missing or bogus timestamps render nothing`() {
val now = at(2025, 8, 14, 14, 30)
// 0 = 这个键根本没传过来;负数只可能来自解析事故。都不是真实时刻 ⇒ 一个字都不画。
assertNull(messageTimeLabel(0L, now, shanghai))
assertNull(messageTimeLabel(-1L, now, shanghai))
assertNull(messageTimeLabel(Long.MIN_VALUE, now, shanghai))
assertNull(messageTimeLabel(-1L, now, utc))
}
// ---------------------------------------------------------------- 时区
@Test
fun `the day boundary follows the injected zone`() {
// 同一毫秒:上海是 8 月 15 日 07:30(今天),UTC 还是 8 月 14 日 23:30(昨天)。
val millis = at(2025, 8, 14, 23, 30, utc)
val now = at(2025, 8, 15, 1, 0, utc)
assertEquals("07:30", messageTimeLabel(millis, now, shanghai))
assertEquals("昨天 23:30", messageTimeLabel(millis, now, utc))
}
}