Files
Agent-System/web/src/components/chat/InteractiveCard.test.ts
T

372 lines
13 KiB
TypeScript

import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, it, expect } from 'vitest';
import { InteractiveCardData, CardActionItem } from '../../types/workbench';
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('提醒');
});
});
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');
});
});
});