Files
makelore/tests/unit/cloud-chat-timestamps.test.tsx
T

178 lines
10 KiB
TypeScript

import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudChat } from '@/pages/CloudAgents/CloudChat';
import type { CloudHistory, CloudMessage, CloudRun, CloudThread } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), download: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; }
let stream: Stream;
let history: CloudHistory;
const now = new Date(2026, 9, 13, 17, 30, 40);
const sent = new Date(2026, 9, 13, 17, 24, 12).toISOString();
const replied = new Date(2026, 9, 13, 17, 25, 34).toISOString();
const run: CloudRun = { agent_run_id: 'run', request_id: 'request', thread_id: 'thread', agent_slug: 'agent', status: 'running', output: '', version: '1' };
const thread: CloudThread = { thread_id: 'thread', client_thread_id: 'client', agent_slug: 'agent', mode: 'published', status: 'running', title: '对话', updated_at: '', unread: false, run_id: 'run' };
const saved = (fields: Partial<CloudMessage> = {}): CloudMessage => ({
id: 1, role: 'assistant', message_id: 'answer', run_id: 'run', request_id: 'request', created_at: replied, content: '已经保存的回答', ...fields,
});
const articleFor = (text: string) => {
const article = screen.getByText(text).closest('article');
expect(article).not.toBeNull();
return article!;
};
async function emitAnswer(id = 'answer', content = '正在写的回答') {
await act(async () => stream.dispatchEvent(new MessageEvent('messages', {
lastEventId: '100-1', data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: {
items: [{ stream_event: { type: 'message_delta', message_id: id, content } }],
} }),
})));
}
async function finish(status = 'completed') {
await act(async () => stream.dispatchEvent(new MessageEvent('end', {
lastEventId: '100-1', data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status } }),
})));
}
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(now);
stream = new Stream();
history = { thread_id: 'thread', messages: [], run, next_offset: null };
api.recovery.mockResolvedValue({ recent: null, pending: [] });
api.remember.mockResolvedValue({});
api.events.mockResolvedValue(stream);
api.call.mockImplementation(async (operation: string) => {
if (operation === 'threads') return { threads: [thread], next_offset: null };
if (operation === 'history') return history;
if (operation === 'run') return history.run;
if (operation === 'files') return { files: [] };
if (operation === 'attachments') return { attachments: [] };
if (operation === 'viewed') return {};
throw new Error(operation);
});
});
afterEach(() => { cleanup(); vi.useRealTimers(); });
it('displays cloud creation times for sent questions and replies with semantic time and full precision', async () => {
history = { ...history, run: { ...run, status: 'completed' }, messages: [
saved({ id: 1, role: 'user', message_id: 'question', created_at: sent, content: '已经发送的问题' }),
saved({ id: 2 }),
] };
const view = render(<CloudChat slug="agent" />);
await screen.findByText('已经保存的回答');
const questionTime = within(articleFor('已经发送的问题')).getByLabelText('发送时间:2026年10月13日 17:24:12');
expect(questionTime.tagName).toBe('TIME');
expect(questionTime).toHaveTextContent(/^17:24$/);
expect(questionTime).toHaveAttribute('datetime', sent);
expect(questionTime).toHaveAttribute('title', '2026年10月13日 17:24:12');
const replyTime = within(articleFor('已经保存的回答')).getByLabelText('回复时间:2026年10月13日 17:25:34');
expect(replyTime).toHaveTextContent(/^17:25$/);
expect(replyTime).toHaveAttribute('datetime', replied);
expect(view.container.querySelectorAll('time')).toHaveLength(2);
expect(api.events).not.toHaveBeenCalled();
});
it('includes the date for older messages and the year when needed', async () => {
const yesterday = new Date(2026, 9, 12, 23, 59, 58).toISOString();
const lastYear = new Date(2025, 9, 12, 9, 5, 6).toISOString();
history = { ...history, run: { ...run, status: 'completed' }, messages: [
saved({ id: 1, role: 'user', created_at: yesterday, content: '昨天的问题' }),
saved({ id: 2, created_at: lastYear, content: '去年的回答' }),
] };
render(<CloudChat slug="agent" />);
await screen.findByText('去年的回答');
expect(within(articleFor('昨天的问题')).getByText('10月12日 23:59')).toHaveAttribute('datetime', yesterday);
expect(within(articleFor('去年的回答')).getByText('2025年10月12日 09:05')).toHaveAttribute('title', '2025年10月12日 09:05:06');
});
it('does not fabricate timestamps for legacy or invalid cloud records', async () => {
history = { ...history, run: { ...run, status: 'completed' }, messages: [
saved({ id: 1, role: 'user', created_at: '', content: '没有记录时间的问题' }),
saved({ id: 2, created_at: 'invalid-date', content: '时间无效的回答' }),
] };
const view = render(<CloudChat slug="agent" />);
await screen.findByText('时间无效的回答');
expect(within(articleFor('没有记录时间的问题')).getByText('时间未记录')).toHaveAttribute('data-message-time', 'unknown');
expect(within(articleFor('时间无效的回答')).getByText('时间未记录')).toHaveAttribute('data-message-time', 'unknown');
expect(view.container.querySelector('time')).toBeNull();
});
it('preserves the original cloud timestamp when a conversation is read again on a later day', async () => {
history = { ...history, run: { ...run, status: 'completed' }, messages: [saved()] };
const view = render(<CloudChat slug="agent" />);
await screen.findByText('已经保存的回答');
expect(within(articleFor('已经保存的回答')).getByText('17:25')).toHaveAttribute('datetime', replied);
view.unmount();
vi.setSystemTime(new Date(2026, 9, 14, 10, 0, 0));
render(<CloudChat slug="agent" />);
await screen.findByText('已经保存的回答');
expect(within(articleFor('已经保存的回答')).getByText('10月13日 17:25')).toHaveAttribute('datetime', replied);
expect(api.call.mock.calls.filter(([operation]) => operation === 'history')).toHaveLength(2);
});
it('replaces the pending reply marker with its authoritative cloud timestamp once, after final history arrives', async () => {
const view = render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalledWith('run'));
await emitAnswer();
expect(within(articleFor('正在写的回答')).getByText('回复中…')).toHaveAttribute('data-message-time', 'pending');
expect(view.container.querySelector('time')).toBeNull();
history = { ...history, run: { ...run, status: 'completed' }, messages: [saved({ content: '完整的已保存回答' })] };
await finish();
await screen.findByText('完整的已保存回答');
expect(screen.queryByText('正在写的回答')).not.toBeInTheDocument();
expect(screen.queryByText('回复中…')).not.toBeInTheDocument();
expect(view.container.querySelectorAll('time')).toHaveLength(1);
expect(within(articleFor('完整的已保存回答')).getByLabelText('回复时间:2026年10月13日 17:25:34')).toHaveAttribute('datetime', replied);
});
it('keeps a known source timestamp when live content replaces its partial saved history', async () => {
history = { ...history, messages: [saved({ content: '已保存的片段' })] };
const view = render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalled());
await emitAnswer('answer', '实时补全的回答');
expect(screen.queryByText('已保存的片段')).not.toBeInTheDocument();
expect(within(articleFor('实时补全的回答')).getByLabelText('回复时间:2026年10月13日 17:25:34')).toHaveAttribute('datetime', replied);
expect(view.container.querySelectorAll('time')).toHaveLength(1);
expect(screen.queryByText('回复中…')).not.toBeInTheDocument();
});
it('retains the original date if an older source message is replayed by a newer run', async () => {
const oldTime = new Date(2026, 9, 11, 8, 15, 3).toISOString();
history = { ...history, messages: [saved({ run_id: 'old-run', request_id: 'old-request', created_at: oldTime, content: '旧回答的历史记录' })] };
render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalled());
await emitAnswer('answer', '旧回答再次推送的内容');
const replay = articleFor('旧回答再次推送的内容');
expect(within(replay).getByLabelText('回复时间:2026年10月11日 08:15:03')).toHaveTextContent('10月11日 08:15');
expect(replay.querySelector('time')).toHaveAttribute('datetime', oldTime);
expect(replay.querySelector('time')).not.toHaveAttribute('datetime', now.toISOString());
});
it('does not borrow a question timestamp for a streamed reply with the same source id', async () => {
history = { ...history, messages: [saved({ role: 'user', created_at: sent, content: '历史中的问题' })] };
render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalled());
await emitAnswer();
const reply = articleFor('正在写的回答');
expect(within(reply).getByText('回复中…')).toHaveAttribute('data-message-time', 'pending');
expect(reply.querySelector('time')).toBeNull();
});
it('marks a terminal unsaved reply as unrecorded if history refresh fails instead of using the local clock', async () => {
render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalled());
await emitAnswer();
const implementation = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, ...args) => {
if (operation === 'history') throw new Error('历史暂不可用');
return implementation(operation, ...args);
});
await finish('failed');
await screen.findByText('历史暂不可用');
const reply = articleFor('正在写的回答');
expect(within(reply).getByText('时间未记录')).toHaveAttribute('data-message-time', 'unknown');
expect(reply.querySelector('time')).toBeNull();
expect(screen.queryByText('回复中…')).not.toBeInTheDocument();
});