372 lines
13 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|