import { beforeEach, describe, expect, it, vi } from 'vitest' import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { setPersistedOpen } from '@ui' import type { ActivityCard, HITLCard, Message } from '../src/contracts' import { Conversation } from '../src/components/Conversation' import { ConversationWork } from '../src/components/ConversationWork' import { ProcessTimeline } from '../src/components/ProcessTimeline' import { store, type LiveState } from '../src/store' // SSR has no browser subscription snapshot. Keep the production store/actions and // real UI renderers; substitute only the hook boundary with a complete snapshot. const snapshot = vi.hoisted(() => ({ state: undefined as ReturnType | undefined })) vi.mock('../src/store', async importOriginal => { const actual = await importOriginal() return { ...actual, useStore: (select: (s: ReturnType) => unknown) => select(snapshot.state!) } }) const timestamp = '2026-10-12T10:00:00Z' function card(over: Partial = {}): ActivityCard { return { id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1', kind: 'progress', title: '整理项目材料', status: 'active', tone: 'info', body: [{ type: 'kv', kv: [['状态', '进行中'], ['正在做', '核对会议结论'], ['材料键', 'private-material-key']] }], createdAt: timestamp, updatedAt: timestamp, ...over, } } function reply(parts: Message['parts']): Message { return { id: 'msg_1', conversationId: 'cnv_1', role: 'assistant', runId: 'run_1', createdAt: timestamp, parts } } function hitl(over: Partial = {}): HITLCard { return { id: 'hitl_detail1', runId: 'run_1', conversationId: 'cnv_1', kind: 'confirm', question: '执行工具 run_shell?', detail: '{"command":"printf confirm-record"}', choices: [{ id: 'yes', label: '执行', description: '按已确认的参数调用', impact: '会访问外部资源并返回结果' }, { id: 'no', label: '取消', description: '跳过本次调用', impact: '不会执行本次操作' }], allowFreeInput: false, createdAt: timestamp, resolved: false, ...over } } function render() { return renderToStaticMarkup(createElement(Conversation)) } function renderWork() { return renderToStaticMarkup(createElement(ConversationWork)) } function completedResults(): ActivityCard[] { // The host lists cards newest-first; submit_card publishes markdown results. return [ card({ id: 'result_latest', kind: 'result', title: '最新整理成果', status: 'done', tone: 'ok', updatedAt: '2026-10-12T12:00:00Z', body: [{ type: 'markdown', markdown: '最新成果的主要结论' }] }), card({ id: 'result_older', kind: 'result', title: '先前整理成果', status: 'done', tone: 'ok', pinned: true, updatedAt: '2026-10-12T10:00:00Z', body: [{ type: 'markdown', markdown: '先前成果的主要结论' }] }), ] } beforeEach(() => { setPersistedOpen('hitl-details:cnv_1:hitl_detail1', false) setPersistedOpen('conversation-work:prj_1:cnv_1', false) const base = store.getState() snapshot.state = { ...base, currentProjectId: 'prj_1', currentConvId: 'cnv_1', connected: true, tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '讨论项目', createdAt: timestamp, updatedAt: timestamp }], messages: [], cards: [], runs: [], activeHitl: [], live: null, artifacts: [], outbox: [], posting: false, ui: { ...base.ui, rightCollapsed: true, autoCollapseProcess: true }, cardActionStates: {}, } }) describe('conversation work reading', () => { it('keeps ordinary Q&A free of an empty progress object', () => { snapshot.state!.messages = [reply([{ type: 'text', text: '可以先确定项目目标' }])] const html = render() expect(html).toContain('可以先确定项目目标') expect(html).not.toContain('data-conversation-work') expect(html).not.toContain('data-testid="input-status"') }) it('renders stable semantic timestamps on user and assistant messages', () => { snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理材料' }]), id: 'user', role: 'user' }, reply([{ type: 'text', text: '材料已收到' }])] const html = render() expect(html.match(/