test(web): cover real temp vs server message identity
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
e1a9b302ba
commit
7c830146f5
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user