import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; import { InteractiveCardData, CardActionItem, ChatMessage } from '../../types/workbench'; import { MessageBubble } from './MessageBubble'; import { MessageStream } from './MessageStream'; import { InteractiveCard, getCardTypeMeta, formatResolutionSummary, formatStatusInfo, isCardActionable, resolveRiskBadge, computeCardState, calculateRemainingSeconds, } from './InteractiveCard'; describe('InteractiveCard Logic', () => { it('should format resolution text correctly', () => { const card: InteractiveCardData = { cardId: 'c-1', cardType: 'CONFIRMATION', title: '审核驳回流转确认', status: 'resolved', ttlTurns: 3, actions: [], resolutionSummary: '已正式退回编制阶段,批注已挂载', resolvedBy: '李主管', resolvedAt: '16:10', }; expect(card.status).toBe('resolved'); expect(card.resolutionSummary).toContain('已正式退回编制阶段'); const formatted = formatResolutionSummary(card); expect(formatted.summary).toBe('已正式退回编制阶段,批注已挂载'); expect(formatted.operator).toBe('李主管'); expect(formatted.time).toBe('16:10'); }); describe('Card Type Visual Metas', () => { it('should provide proper metadata for all 4 card types', () => { const confMeta = getCardTypeMeta('CONFIRMATION'); expect(confMeta.label).toBe('流转确认'); expect(confMeta.badgeText).toContain('确认'); const slotMeta = getCardTypeMeta('SLOT_FILLING'); expect(slotMeta.label).toBe('要素补全'); expect(slotMeta.badgeText).toContain('补全'); const diffMeta = getCardTypeMeta('DIFF_PROPOSAL'); expect(diffMeta.label).toBe('变更提案'); expect(diffMeta.badgeText).toContain('提案'); const reminderMeta = getCardTypeMeta('REMINDER_NOTICE'); expect(reminderMeta.label).toBe('合规提醒'); expect(reminderMeta.badgeText).toContain('提醒'); const planMeta = getCardTypeMeta('PLAN_CONFIRM'); expect(planMeta.label).toBe('计划确认'); expect(planMeta.badgeText).toContain('计划'); const impactMeta = getCardTypeMeta('SUPPLEMENT_IMPACT_CONFIRM'); expect(impactMeta.label).toContain('补充影响'); expect(impactMeta.badgeText).toContain('影响'); }); }); describe('Status States and Banners', () => { it('should identify active actionable state', () => { expect(isCardActionable('active')).toBe(true); expect(isCardActionable('executing')).toBe(false); expect(isCardActionable('resolved')).toBe(false); expect(isCardActionable('cancelled')).toBe(false); expect(isCardActionable('outdated')).toBe(false); expect(isCardActionable('expired')).toBe(false); }); it('should format resolved status banner', () => { const info = formatStatusInfo('resolved'); expect(info.badgeText).toBe('✅ 已生效'); expect(info.isResolved).toBe(true); }); it('should format outdated status banner', () => { const info = formatStatusInfo('outdated'); expect(info.badgeText).toBe('⚠️ 文档状态已变更,操作失效'); expect(info.isOutdated).toBe(true); }); it('should format expired status banner', () => { const info = formatStatusInfo('expired'); expect(info.badgeText).toBe('⏱️ 已超时失效'); expect(info.isExpired).toBe(true); }); it('should format cancelled status banner', () => { const info = formatStatusInfo('cancelled'); expect(info.badgeText).toBe('🚫 已放弃'); expect(info.isCancelled).toBe(true); }); it('should format executing status banner', () => { const info = formatStatusInfo('executing'); expect(info.badgeText).toContain('执行中'); expect(info.isExecuting).toBe(true); }); }); describe('Action payload and execution idempotency', () => { it('should correctly match payload and styles for action items', () => { const actions: CardActionItem[] = [ { actionId: 'confirm_reject', label: '确认驳回', style: 'danger', payload: { docId: 'doc-002', reason: '缺少指标', }, isAffirmativeDefault: true, }, { actionId: 'cancel', label: '放弃操作', style: 'subtle', }, ]; expect(actions[0].style).toBe('danger'); expect(actions[0].isAffirmativeDefault).toBe(true); expect(actions[0].payload?.docId).toBe('doc-002'); expect(actions[0].payload?.reason).toBe('缺少指标'); expect(actions[1].style).toBe('subtle'); expect(actions[1].isAffirmativeDefault).toBeFalsy(); }); it('should maintain idempotency during state transitions', () => { // 模拟初始 active 状态 const state1 = computeCardState('active', null); expect(state1.effectiveStatus).toBe('active'); expect(state1.canClick).toBe(true); // 用户点击后进入 executing const state2 = computeCardState('active', 'executing'); expect(state2.effectiveStatus).toBe('executing'); expect(state2.canClick).toBe(false); // 后端推送 resolved 状态 const state3 = computeCardState('resolved', null); expect(state3.effectiveStatus).toBe('resolved'); expect(state3.canClick).toBe(false); // 后端推送 outdated 状态 const state4 = computeCardState('outdated', null); expect(state4.effectiveStatus).toBe('outdated'); expect(state4.canClick).toBe(false); // 撤回操作触发局部 executing 状态(即便外部卡片当前仍为 resolved) const state5 = computeCardState('resolved', 'executing'); expect(state5.effectiveStatus).toBe('executing'); expect(state5.canClick).toBe(false); }); }); describe('Grace Period Undo and 60s Countdown', () => { it('should calculate remaining seconds correctly based on timestamps', () => { const now = 1700000000000; // 20 秒前生效:应剩余 40 秒 const resolved20sAgo = new Date(now - 20000).toISOString(); expect(calculateRemainingSeconds(resolved20sAgo, null, 60, now)).toBe(40); // 60 秒前生效:应剩余 0 秒 const resolved60sAgo = new Date(now - 60000).toISOString(); expect(calculateRemainingSeconds(resolved60sAgo, null, 60, now)).toBe(0); // 75 秒前生效(已超时):应归零 const resolved75sAgo = new Date(now - 75000).toISOString(); expect(calculateRemainingSeconds(resolved75sAgo, null, 60, now)).toBe(0); // 当 resolvedAt 无法解析时,使用 localResolvedAt 回退时间戳 const localFallback = now - 15000; expect(calculateRemainingSeconds('16:10', localFallback, 60, now)).toBe(45); // 无任何时间戳时默认返回完整 60 秒 expect(calculateRemainingSeconds(undefined, null, 60, now)).toBe(60); }); it('should render undo bar within grace period and hide it when expired', () => { // 1. 处于 60 秒合法窗口期内的已驳回卡片 (例如生效于 10 秒前) const recentCard: InteractiveCardData = { cardId: 'c-undo-1', cardType: 'CONFIRMATION', title: '审核驳回流转确认', status: 'resolved', ttlTurns: 3, actions: [], resolutionSummary: '已正式退回编制阶段', resolvedBy: '李主管', resolvedAt: new Date(Date.now() - 10000).toISOString(), }; const htmlWithUndo = renderToStaticMarkup( React.createElement(InteractiveCard, { card: recentCard }) ); expect(htmlWithUndo).toContain('撤回驳回 (恢复为待审核,剩余'); expect(htmlWithUndo).toContain('已生效'); // 2. 超过 60 秒窗口期的已生效卡片 (例如生效于 70 秒前) const expiredWindowCard: InteractiveCardData = { cardId: 'c-undo-2', cardType: 'CONFIRMATION', title: '审核驳回流转确认', status: 'resolved', ttlTurns: 3, actions: [], resolutionSummary: '已正式退回编制阶段', resolvedBy: '李主管', resolvedAt: new Date(Date.now() - 70000).toISOString(), }; const htmlWithoutUndo = renderToStaticMarkup( React.createElement(InteractiveCard, { card: expiredWindowCard }) ); expect(htmlWithoutUndo).not.toContain('撤回驳回'); expect(htmlWithoutUndo).toContain('已生效'); }); }); describe('ttlTurns <= 0 Safeguards and Expiration', () => { it('should defensively evaluate effective status as expired when ttlTurns <= 0', () => { // 1. ttlTurns === 0 const stateZero = computeCardState('active', null, 0); expect(stateZero.effectiveStatus).toBe('expired'); expect(stateZero.canClick).toBe(false); // 2. ttlTurns < 0 const stateNegative = computeCardState('active', null, -2); expect(stateNegative.effectiveStatus).toBe('expired'); expect(stateNegative.canClick).toBe(false); // 3. ttlTurns > 0 保持 active const stateNormal = computeCardState('active', null, 3); expect(stateNormal.effectiveStatus).toBe('active'); expect(stateNormal.canClick).toBe(true); // 4. 支持对象入参重载 const stateObj = computeCardState({ status: 'active', ttlTurns: 0 }); expect(stateObj.effectiveStatus).toBe('expired'); expect(stateObj.canClick).toBe(false); }); it('should render expired banner when card ttlTurns <= 0', () => { const expiredTtlCard: InteractiveCardData = { cardId: 'c-ttl-0', cardType: 'DIFF_PROPOSAL', title: '差异变更提案', status: 'active', ttlTurns: 0, actions: [ { actionId: 'apply', label: '采纳变更', style: 'primary', isAffirmativeDefault: true }, ], }; const html = renderToStaticMarkup( React.createElement(InteractiveCard, { card: expiredTtlCard }) ); expect(html).toContain('⏱️ 已超时失效'); // 超时后操作按钮区不得渲染 expect(html).not.toContain('采纳变更'); }); }); describe('Executing Banner and Lock Safeguards', () => { it('should render executing banner when card is in executing status', () => { const executingCard: InteractiveCardData = { cardId: 'c-exec-1', cardType: 'CONFIRMATION', title: '审核驳回流转确认', status: 'executing', ttlTurns: 3, actions: [ { actionId: 'confirm', label: '确认驳回', style: 'danger' }, ], }; const html = renderToStaticMarkup( React.createElement(InteractiveCard, { card: executingCard }) ); expect(html).toContain('⚡ 操作执行中,防重锁定中...'); expect(html).toContain('执行中'); }); }); describe('CSS Variable Syntax and Outline Styling', () => { it('should render valid CSS variable border for risk badges without hex alpha suffix', () => { const highRiskCard: InteractiveCardData = { cardId: 'c-risk-1', cardType: 'REMINDER_NOTICE', title: '高合规风险预警', impactRisk: '高风险:强监管条款', status: 'active', ttlTurns: 2, actions: [], }; const html = renderToStaticMarkup( React.createElement(InteractiveCard, { card: highRiskCard }) ); // 必须是合法的 CSS 变量,严禁出现 var(--danger)33 等语法错误 expect(html).toContain('border:1px solid var(--danger)'); expect(html).not.toContain('var(--danger)33'); }); it('should apply matched outline colors for affirmative default buttons', () => { // 危险类型默认肯定按钮 (danger outline) const dangerCard: InteractiveCardData = { cardId: 'c-outline-danger', cardType: 'CONFIRMATION', title: '确认驳回', status: 'active', ttlTurns: 3, actions: [ { actionId: 'reject', label: '确认驳回', style: 'danger', isAffirmativeDefault: true, }, ], }; const htmlDanger = renderToStaticMarkup( React.createElement(InteractiveCard, { card: dangerCard }) ); expect(htmlDanger).toContain('outline:1.5px solid var(--danger)'); // 主品牌色默认肯定按钮 (brand-500 outline) const primaryCard: InteractiveCardData = { cardId: 'c-outline-primary', cardType: 'CONFIRMATION', title: '确认流转', status: 'active', ttlTurns: 3, actions: [ { actionId: 'pass', label: '确认通过', style: 'primary', isAffirmativeDefault: true, }, ], }; const htmlPrimary = renderToStaticMarkup( React.createElement(InteractiveCard, { card: primaryCard }) ); expect(htmlPrimary).toContain('outline:1.5px solid var(--brand-500)'); }); }); describe('Risk Badge and Anchoring', () => { it('should resolve risk badges correctly', () => { const high = resolveRiskBadge('高风险:可能导致重测'); expect(high.level).toBe('high'); expect(high.color).toContain('danger'); const medium = resolveRiskBadge('中风险'); expect(medium.level).toBe('medium'); expect(medium.color).toContain('amber'); const low = resolveRiskBadge('低风险'); expect(low.level).toBe('low'); expect(low.color).toContain('success'); const none = resolveRiskBadge(undefined); expect(none.level).toBe('none'); }); }); describe('MessageBubble and MessageStream Card Integration', () => { it('should render InteractiveCard inside MessageBubble when message has interactiveCard', () => { const cardData: InteractiveCardData = { cardId: 'card-bubble-test', cardType: 'CONFIRMATION', title: '审核驳回与返工确认', targetDocId: 'doc-002', targetDocTitle: '微创外科全球竞品深度调研', sectionTitle: '第3节「电气安全与EMC」', status: 'active', ttlTurns: 3, actions: [ { actionId: 'confirm_reject', label: '确认驳回', style: 'danger', isAffirmativeDefault: true }, { actionId: 'cancel', label: '放弃操作', style: 'subtle' }, ], }; const msg: ChatMessage = { id: 'msg-ast-1', sessionId: 'sess-001', role: 'assistant', content: '检测到您提出驳回意图,请确认是否执行:', timestamp: '18:30', interactiveCard: cardData, }; const html = renderToStaticMarkup( React.createElement(MessageBubble, { message: msg }) ); // 气泡内应包含文本、卡片标题、徽章与操作按钮 expect(html).toContain('检测到您提出驳回意图'); expect(html).toContain('审核驳回与返工确认'); expect(html).toContain('微创外科全球竞品深度调研'); expect(html).toContain('第3节「电气安全与EMC」'); expect(html).toContain('确认驳回'); expect(html).toContain('放弃操作'); }); it('should render InteractiveCard in MessageStream with multiple messages', () => { const cardData: InteractiveCardData = { cardId: 'card-stream-test', cardType: 'CONFIRMATION', title: '流转确认门禁', status: 'active', ttlTurns: 2, actions: [ { actionId: 'confirm', label: '确认执行', style: 'primary', isAffirmativeDefault: true }, ], }; const messages: ChatMessage[] = [ { id: 'u1', sessionId: 'sess-001', role: 'user', content: '驳回这个文档', timestamp: '18:29', }, { id: 'a1', sessionId: 'sess-001', role: 'assistant', content: '请确认是否驳回:', timestamp: '18:30', interactiveCard: cardData, }, ]; const html = renderToStaticMarkup( React.createElement(MessageStream, { messages }) ); expect(html).toContain('驳回这个文档'); expect(html).toContain('请确认是否驳回:'); expect(html).toContain('流转确认门禁'); expect(html).toContain('确认执行'); }); }); });