Files
AskaEth fe4c702f87
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 未读面 unreadCount 进 message_persisted 帧(契约 v0.76 ②)
- broadcastPersisted 是 Web 与 IM 两条路的**共用**函数 ⇒ 两处口径天然一致,不存在分叉
- 口径 = 绝对未读 + 主人单一已读游标(chat_sessions.read_at)⇒ 多端共享
- 可选键 omitempty:只在 >0 且确实算得出来时出现;0 与「算不出来」都不顶位
  (0 只能从「会话有游标」推出,否则那个 0 就是编的)⇒ 缺席 = 老行为
- Docs/api-client.md:v0.76 ① 按实测改标「未实现且无实现路径」(见下),② 已实现,
  并更正「GET /sessions 的 unreadCount 自 D4 起已在」、新增 §4.13 与 §7.4 第 23 条

⚠️ ① toolCalls[] 本版未落地(取证结论,非推断):chat_messages.turn_id(编排器随机路标识
turn-<16hex>)与 flow_graphs.turn_id(trace.FlowGraph.ID = <session>-<轮次序号>,进程内递增)
不同源,真实库中 66 条消息行 vs 21 条图行字面交集为空 ⇒ 无法 join;按时间窗硬凑属于猜,不做。
补齐路径:链路图带回合标识(新键 + 迁移 0023 起)+ 落盘列,之后 toolCalls[] 是一条批量 SQL。

我亲自复跑的变异:去掉帧里的 n > 0 守卫 ⇒ TestBroadcastPersistedUnreadCountAbsentWhenUnknown
与 TestBroadcastPersistedCarriesUnreadCount 双双 FAIL(断言「0 不该顶位」)⇒ 测试是真护栏。
2026-10-06 07:58:42 +08:00

160 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// 本文件钉住契约 v0.76 的**未读实时面**:`message_persisted` 帧的 data 携带该会话
// 此刻的**绝对未读数**(与 GET /api/v1/sessions 的 unreadCount 同一口径、同一条
// 读游标)。三条硬约束各有断言:值是对的;只在**确实算得出来且非零**时出现
// (缺席 = 老行为);算不出来时绝不冒出一个 0 去冒充「已读完」。
// unreadOf 从帧 data 里读未读数并返回 (值, 是否在场)。它刻意先断言「在场性」:
// 本面的关键取舍就是「缺席」与「0」是两回事。
func unreadOf(t *testing.T, ev wsEvent) (int, bool) {
t.Helper()
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("frame data = %+v, want a JSON object", ev.Data)
}
raw, present := data["unreadCount"]
if !present {
return 0, false
}
n, ok := raw.(float64)
if !ok {
t.Fatalf("frame data.unreadCount = %v (%T), want a number", raw, raw)
}
return int(n), true
}
// TestBroadcastPersistedCarriesUnreadCount 覆盖顺路径与两条边界:帧上的数字是
// **绝对**未读数(不是「自上次以来」),游标推进之后同一条广播路径不再带该键
// (0 不顶位),游标之后的新消息重新点亮并给出刷新后的绝对值。末段顺带钉住
// `channel_persisted` 与 `message_persisted` 走**同一个**载荷构造函数,因此隔离
// 渠道会话(没有 chat_sessions 行)同样不带该键 —— 它连读游标都没有。
func TestBroadcastPersistedCarriesUnreadCount(t *testing.T) {
t.Parallel()
srv, _, _, st := newHistoryEnv(t)
c := newOwnerConn(srv.hub, 32)
srv.hub.register(c)
ctx := context.Background()
appendAt := func(at int64, content string) history.Message {
t.Helper()
full, err := st.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: content, CreatedAt: at,
})
if err != nil {
t.Fatalf("append %q: %v", content, err)
}
return full
}
// ① 历史一条(1000)+ 刚落库的一条(2000),游标仍是 0 ⇒ 两条都未读。
if err := st.Append(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser, Content: "old", CreatedAt: 1000,
}); err != nil {
t.Fatalf("seed old: %v", err)
}
srv.BroadcastPersisted(appendAt(2000, "new"))
ev := readFakeFrame(t, c)
if ev.Type != "message_persisted" {
t.Fatalf("frame type = %q, want message_persisted", ev.Type)
}
if got, present := unreadOf(t, ev); !present || got != 2 {
t.Fatalf("frame data.unreadCount = %d (present=%v), want 2 (绝对未读,不是本次 +1)", got, present)
}
// ② 游标推进到最新消息 ⇒ 该会话已读:同一条广播路径**不再**带该键。
if _, err := st.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(2000)); err != nil {
t.Fatalf("mark session read: %v", err)
}
srv.BroadcastPersisted(appendAt(2000, "exactly at the cursor"))
if got, present := unreadOf(t, readFakeFrame(t, c)); present {
t.Fatalf("frame data.unreadCount = %d,want 该键缺席:未读为 0 时不顶位(缺席 = 老行为,客户端回落 GET /sessions 收敛)", got)
}
// ③ 游标之后的新消息 ⇒ 值是刷新后的绝对值。
srv.BroadcastPersisted(appendAt(3000, "after the cursor"))
if got, present := unreadOf(t, readFakeFrame(t, c)); !present || got != 1 {
t.Fatalf("frame data.unreadCount = %d (present=%v), want 1", got, present)
}
// ④ 隔离渠道会话走同一个构造函数 ⇒ 口径不分叉;它没有游标,因此不带该键。
if err := st.Append(ctx, history.Message{
SessionID: "sess-g", Role: history.RoleUser, Content: "isolated", CreatedAt: 5000,
}); err != nil {
t.Fatalf("seed isolated: %v", err)
}
srv.BroadcastPersisted(history.Message{
ID: "m-iso", SessionID: "sess-g", Role: history.RoleUser, Content: "isolated", CreatedAt: 5000,
})
iso := readFakeFrame(t, c)
if iso.Type != "channel_persisted" {
t.Fatalf("frame type = %q, want channel_persisted", iso.Type)
}
if got, present := unreadOf(t, iso); present {
t.Fatalf("隔离会话的帧带上了 unreadCount = %d,want 该键缺席(没有会话行就没有读游标)", got)
}
}
// TestBroadcastPersistedUnreadCountAbsentWhenUnknown 钉住三条**诚实缺席**路径:
// 未注入 HistoryStore、注入的存储没实现单会话未读视图(`sessionUnreadCounter`)、
// 以及隔离渠道会话本身。三条都不许冒出 `unreadCount:0` —— 把「算不出来」写成
// 「没有未读」就是编数据。
func TestBroadcastPersistedUnreadCountAbsentWhenUnknown(t *testing.T) {
t.Parallel()
pending := history.Message{
ID: "row-1", SessionID: history.MainSessionID, Role: history.RoleUser, Content: "hi", CreatedAt: 1000,
}
t.Run("history not wired", func(t *testing.T) {
srv, _, _ := buildEnv(t, cfgDefault(), Deps{})
c := newOwnerConn(srv.hub, 32)
srv.hub.register(c)
srv.BroadcastPersisted(pending)
if got, present := unreadOf(t, readFakeFrame(t, c)); present {
t.Fatalf("未接线历史存储的帧带上了 unreadCount = %d,want 缺席", got)
}
})
t.Run("store without the unread view", func(t *testing.T) {
srv, _, _ := buildEnv(t, cfgDefault(), Deps{History: &fakeHistory{}})
c := newOwnerConn(srv.hub, 32)
srv.hub.register(c)
srv.BroadcastPersisted(pending)
if got, present := unreadOf(t, readFakeFrame(t, c)); present {
t.Fatalf("未实现 sessionUnreadCounter 的存储替身带上了 unreadCount = %d,want 缺席", got)
}
})
t.Run("isolated session has no cursor", func(t *testing.T) {
srv, _, _, st := newHistoryEnv(t)
c := newOwnerConn(srv.hub, 32)
srv.hub.register(c)
if err := st.Append(context.Background(), history.Message{
SessionID: "sess-g", Role: history.RoleUser, Content: "iso", CreatedAt: 1000,
}); err != nil {
t.Fatalf("seed isolated: %v", err)
}
srv.BroadcastPersisted(history.Message{
ID: "m-iso", SessionID: "sess-g", Role: history.RoleUser, Content: "iso", CreatedAt: 1000,
})
ev := readFakeFrame(t, c)
if ev.Type != "channel_persisted" {
t.Fatalf("frame type = %q, want channel_persisted", ev.Type)
}
if got, present := unreadOf(t, ev); present {
t.Fatalf("隔离会话的帧带上了 unreadCount = %d,want 缺席", got)
}
})
}