test(web): cover real temp vs server message identity

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:21 +00:00
committed by weichen
co-authored by Cursor
parent e1a9b302ba
commit 7c830146f5
2 changed files with 129 additions and 71 deletions
+48
View File
@@ -0,0 +1,48 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isSameMessage,
filterPendingLiveMessages,
mergeLiveOverHistory,
} from './messageIdentity';
import type { ChatMessage } from '../types/workbench';
const base = (patch: Partial<ChatMessage>): ChatMessage => ({
id: 'tmp-1',
sessionId: 'sess-1',
role: 'user',
content: 'hello',
timestamp: '10:00',
...patch,
});
test('isSameMessage: clientTurnId match', () => {
const local = base({ id: 'tmp-user', clientTurnId: 'turn-abc' });
const server = base({ id: 'msg-101', serverId: 101, clientTurnId: 'turn-abc' });
assert.equal(isSameMessage(local, server), true);
});
test('isSameMessage: serverId match without turn', () => {
const local = base({ id: 'ast-123', serverId: 102, role: 'assistant' });
const server = base({ id: 'msg-102', serverId: 102, role: 'assistant' });
assert.equal(isSameMessage(local, server), true);
});
test('filterPendingLiveMessages: real temp vs server id', () => {
const history = [base({ id: 'msg-101', serverId: 101, clientTurnId: 'turn-abc' })];
const live = [
base({ id: 'tmp-user', clientTurnId: 'turn-abc' }),
base({ id: 'tmp-asst', role: 'assistant', clientTurnId: 'turn-abc', serverId: 102 }),
];
const pending = filterPendingLiveMessages(live, history);
assert.equal(pending.length, 1);
assert.equal(pending[0].role, 'assistant');
});
test('mergeLiveOverHistory preserves server id from live', () => {
const history = base({ id: 'msg-101', serverId: 101, content: 'hello' });
const live = base({ id: 'tmp-user', serverId: 101, content: 'hello', status: undefined });
const merged = mergeLiveOverHistory(history, live);
assert.equal(merged.id, 'msg-101');
assert.equal(merged.serverId, 101);
});
+81 -71
View File
@@ -2,6 +2,7 @@ import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { flattenDayStack } from '../hooks/useVirtualMessages';
import { ChatDayBucket, ChatMessage } from '../types/workbench';
import { filterPendingLiveMessages } from '../engine/messageIdentity';
describe('工作流切换与消息展示时序测试套件', () => {
test('1. 当存在历史 seed 消息且追加新实时消息时,新消息必须严格沉淀在末尾', () => {
@@ -12,8 +13,11 @@ describe('工作流切换与消息展示时序测试套件', () => {
{ id: 'seed-4', sessionId: 'sess-1', role: 'assistant', content: '历史答复2', timestamp: '09:11' },
];
const clientTurnId = 'turn-live-abc';
const newlySentUserMsg: ChatMessage = {
id: 'msg-live-1',
id: 'tmp-user-uuid',
clientTurnId,
clientMessageId: `${clientTurnId}:user`,
sessionId: 'sess-1',
role: 'user',
content: '分析一下测试方案中,有哪些比较难的项目,以及需要特别注意的地方',
@@ -21,46 +25,61 @@ describe('工作流切换与消息展示时序测试套件', () => {
};
const newlyRepliedAsstMsg: ChatMessage = {
id: 'ast-live-2',
id: 'tmp-asst-uuid',
clientTurnId,
clientMessageId: `${clientTurnId}:assistant`,
sessionId: 'sess-1',
role: 'assistant',
content: '针对肌骨超声AI辅助诊断系统的系统验证方案...',
timestamp: '10:01',
serverId: 102,
};
const fullLiveMessages: ChatMessage[] = [
...historicalMessages,
newlySentUserMsg,
newlyRepliedAsstMsg,
];
// 切回项目时 live 仅保留进行中/未对账的 temp 消息(历史已由服务端窗口提供)
const fullLiveMessages: ChatMessage[] = [newlySentUserMsg, newlyRepliedAsstMsg];
// 模拟服务端只持久化了新返回的 2 条记录 (原 Bug 触发场景)
// 服务端窗口含历史 + 新落库消息(真实 ID 与本地 temp 不同)
const serverDayStack: ChatDayBucket[] = [
{
dayKey: '2026-09-11',
count: 2,
loadedCount: 2,
count: 6,
loadedCount: 6,
truncated: false,
firstMessageId: 101,
firstMessageId: 97,
lastMessageId: 102,
messages: [
{ ...newlySentUserMsg, serverId: 101 },
{ ...newlyRepliedAsstMsg, serverId: 102 },
...historicalMessages.map((m, i) => ({
...m,
id: `msg-${97 + i}`,
serverId: 97 + i,
})),
{
id: 'msg-101',
clientTurnId,
clientMessageId: `${clientTurnId}:user`,
sessionId: 'sess-1',
role: 'user' as const,
content: newlySentUserMsg.content,
timestamp: '10:00',
serverId: 101,
},
{
id: 'msg-102',
clientTurnId,
clientMessageId: `${clientTurnId}:assistant`,
sessionId: 'sess-1',
role: 'assistant' as const,
content: newlyRepliedAsstMsg.content,
timestamp: '10:01',
serverId: 102,
},
],
},
];
// 运用修复后的保序合并算法
const todayStr = '2026-09-11';
const existingMsgIds = new Set<string>();
for (const b of serverDayStack) {
for (const m of b.messages) {
existingMsgIds.add(m.id);
if (m.serverId) existingMsgIds.add(`msg-${m.serverId}`);
}
}
const pendingLive = fullLiveMessages.filter((m) => !existingMsgIds.has(m.id));
const historyMessages = serverDayStack.flatMap((b) => b.messages);
const pendingLive = filterPendingLiveMessages(fullLiveMessages, historyMessages);
const result = serverDayStack.map((b) => ({ ...b, messages: [...b.messages] }));
const todayBucket = result.find((b) => b.dayKey === todayStr)!;
@@ -87,58 +106,49 @@ describe('工作流切换与消息展示时序测试套件', () => {
const flat = flattenDayStack(result);
// 校验总数
assert.equal(flat.length, 6);
// 校验第 1 条依然是历史第一条,绝对没有被反转
assert.equal(flat[0].id, 'seed-1');
assert.equal(flat[1].id, 'seed-2');
assert.equal(flat[2].id, 'seed-3');
assert.equal(flat[3].id, 'seed-4');
// 校验最后两条为刚刚发送的用户提问和助手回复,严格在最底部
assert.equal(flat[4].id, 'msg-live-1');
assert.equal(flat[5].id, 'ast-live-2');
assert.equal(pendingLive.length, 0);
assert.equal(flat.filter((m) => m.clientTurnId === clientTurnId).length, 2);
assert.equal(flat[4].id, 'msg-101');
assert.equal(flat[5].id, 'msg-102');
});
test('2. 页面刷新后,即使本地 liveMessages 未包含新消息,按时间戳与自增 ID 排序也绝对不倒挂在历史消息前', () => {
// 模拟服务端返回完整按时间排布的消息
const historicalMessages: ChatMessage[] = [
{ id: 'm-jigu-1', sessionId: 'sess-jigu-u1', role: 'system', content: '助手就位', timestamp: '09:00', createdAt: '2026-09-11T09:00:00+08:00', serverId: 25217 },
{ id: 'm-jigu-2', sessionId: 'sess-jigu-u1', role: 'user', content: '用户需求', timestamp: '09:14', createdAt: '2026-09-11T09:14:00+08:00', serverId: 25218 },
test('2. 切回项目时相同正文不应因 temp/server id 差异重复展示', () => {
const clientTurnId = 'turn-jigu-dup';
const liveMessages: ChatMessage[] = [
{
id: 'tmp-user',
clientTurnId,
sessionId: 'sess-jigu-u1',
role: 'user',
content: '分析一下测试方案中...',
timestamp: '10:06',
serverId: 25227,
},
];
const serverRecentMessages: ChatMessage[] = [
{ id: 'msg-25227', sessionId: 'sess-jigu-u1', role: 'user', content: '分析一下测试方案中...', timestamp: '10:06', createdAt: '2026-09-11T10:06:46+08:00', serverId: 25227 },
{ id: 'msg-25228', sessionId: 'sess-jigu-u1', role: 'assistant', content: '针对测试方案...', timestamp: '10:07', createdAt: '2026-09-11T10:07:26+08:00', serverId: 25228 },
const serverDayStack: ChatDayBucket[] = [
{
dayKey: '2026-09-11',
count: 1,
loadedCount: 1,
truncated: false,
firstMessageId: 25227,
lastMessageId: 25227,
messages: [
{
id: 'msg-25227',
clientTurnId,
sessionId: 'sess-jigu-u1',
role: 'user',
content: '分析一下测试方案中...',
timestamp: '10:06',
serverId: 25227,
},
],
},
];
const allInBucket = [...serverRecentMessages, ...historicalMessages]; // 故意打乱测试排序算法鲁棒性
const getMessageSortTime = (m: ChatMessage): number => {
if (m.createdAt) {
const t = new Date(m.createdAt).getTime();
if (!isNaN(t)) return t;
}
if (m.timestamp && m.timestamp.includes(':')) {
const [hh, mm] = m.timestamp.split(':').map(Number);
if (!isNaN(hh) && !isNaN(mm)) {
const d = new Date();
d.setHours(hh, mm, 0, 0);
return d.getTime();
}
}
return 0;
};
allInBucket.sort((a, b) => {
const tA = getMessageSortTime(a);
const tB = getMessageSortTime(b);
if (tA !== 0 && tB !== 0 && tA !== tB) return tA - tB;
return (a.serverId || 0) - (b.serverId || 0);
});
assert.equal(allInBucket[0].id, 'm-jigu-1');
assert.equal(allInBucket[1].id, 'm-jigu-2');
assert.equal(allInBucket[2].id, 'msg-25227');
assert.equal(allInBucket[3].id, 'msg-25228');
const history = serverDayStack.flatMap((b) => b.messages);
const pending = filterPendingLiveMessages(liveMessages, history);
assert.equal(pending.length, 0);
});
});