Files
novalon-website/src/components/detail/detail.test.tsx
T
zhangxiang a366bd1400 fix(qa): 第五轮系统性质量保障缺陷修复 + 单元/集成测试
安全/鉴权:登出清服务端 httpOnly 令牌、登录 IP 限流前置 bcrypt、停用账号
令牌在 6 个内联 role handler 失效、admin/items 存在性 oracle 认证前置、
同意偏好 sanitize fail-closed、api-crypto 按 HTTP 方法判体。

CMS:workflow 状态更新+审计收进交互式 $transaction(失败即回滚)、编辑器
richtext 走 textarea 防换行损毁、array/object 子字段按作用域读写防污染、
about/contact/erp-upgrade 补 ISR revalidate 与路由映射。

UI/a11y:AnimatedCounter 入视口前显起点、CLS 累计上报、MetricCard 动画入
effect 并卸载取消 rAF、后台状态四态映射/搜索防抖+序号守卫/媒体单 input/
吞错改横幅/表单 label-aria 关联。

新增对应判别性单测与 pagination/rate-limit/client-ip/sanitize/validate-content-data
等模块及 tests-integration 真库集成层。
2026-09-28 10:48:07 +08:00

642 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen, act } from '@testing-library/react';
import '@testing-library/jest-dom';
// ─── Mocks ───────────────────────────────────────────────────────────────
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, variants, whileInView, whileHover, whileTap, viewport, transition, className, onMouseMove, onMouseEnter, onMouseLeave, onMouseUp, style, ref, href, ...props }: any) => (
href ? (
<a
data-testid="motion-a"
href={href}
className={className}
style={style}
data-variants={JSON.stringify(variants)}
data-while-hover={JSON.stringify(whileHover)}
data-while-tap={JSON.stringify(whileTap)}
{...props}
>
{children}
</a>
) : (
<div
data-testid="motion-div"
data-initial={JSON.stringify(initial)}
data-animate={JSON.stringify(animate)}
data-variants={JSON.stringify(variants)}
data-while-hover={JSON.stringify(whileHover)}
data-while-tap={JSON.stringify(whileTap)}
data-while-inview={JSON.stringify(whileInView)}
data-viewport={JSON.stringify(viewport)}
data-transition={JSON.stringify(transition)}
className={className}
style={style}
ref={ref}
onMouseMove={onMouseMove}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseUp={onMouseUp}
{...props}
>
{children}
</div>
)
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
h2: ({ children, className, ...props }: any) => (
<h2 data-testid="motion-h2" className={className} {...props}>{children}</h2>
),
h3: ({ children, className, ...props }: any) => (
<h3 data-testid="motion-h3" className={className} {...props}>{children}</h3>
),
span: ({ children, className, ...props }: any) => (
<span data-testid="motion-span" className={className} {...props}>{children}</span>
),
button: ({ children, onClick, className, ...props }: any) => (
<button
data-testid="motion-button"
onClick={onClick}
className={className}
{...props}
>
{children}
</button>
),
a: ({ children, href, className, ...props }: any) => (
<a
data-testid="motion-a"
href={href}
className={className}
{...props}
>
{children}
</a>
),
li: ({ children, className, ...props }: any) => (
<li data-testid="motion-li" className={className} {...props}>{children}</li>
),
circle: ({ className, cx, cy, r, fill, stroke, strokeWidth, strokeLinecap, strokeDasharray, ...props }: any) => (
<circle
data-testid="motion-circle"
className={className}
cx={cx}
cy={cy}
r={r}
fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap={strokeLinecap}
strokeDasharray={strokeDasharray}
{...props}
/>
),
},
useInView: jest.fn(() => true),
useMotionValue: jest.fn(() => ({ get: () => 0.5, set: jest.fn() })),
useTransform: jest.fn(() => ({ get: () => '0%' })),
useSpring: jest.fn(() => ({ get: () => 0 })),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('@/hooks/use-reduced-motion', () => ({
useReducedMotion: jest.fn(() => false),
}));
jest.mock('./micro-interactions', () => ({
TiltCard: ({ children, className }: any) => (
<div data-testid="tilt-card" className={className}>{children}</div>
),
PressableButton: ({ children, className, variant, onClick }: any) => (
<button
data-testid="pressable-button"
data-variant={variant}
className={className}
onClick={onClick}
>
{children}
</button>
),
HoverLink: ({ children, href, className }: any) => (
<a data-testid="hover-link" href={href} className={className}>{children}</a>
),
}));
jest.mock('./detail-case-study', () => ({
CaseStudyCard: ({ study, index }: any) => (
<div data-testid="case-study-card" data-index={index}>
<span data-testid="case-client">{study.client}</span>
<span data-testid="case-industry">{study.industry}</span>
</div>
),
}));
jest.mock('./detail-certification', () => ({
CertificationList: ({ certifications }: any) => (
<div data-testid="certification-list">
{certifications.map((cert: any, i: number) => (
<span key={i} data-testid="cert-name">{cert.name}</span>
))}
</div>
),
}));
jest.mock('lucide-react', () => {
const icons: Record<string, any> = {};
const names = [
'ArrowRight', 'Sparkles', 'ChevronDown', 'Zap', 'Shield',
'CheckCircle2', 'MessageCircle', 'Download', 'Package',
'Lightbulb', 'Wrench', 'TrendingUp', 'Clock', 'Award',
'Users', 'Cpu', 'ExternalLink',
];
for (const name of names) {
const Icon = (props: any) => (
<svg
data-testid={`icon-${name.toLowerCase().replace(/\s+/g, '-')}`}
className={props?.className}
/>
);
Icon.displayName = name;
icons[name] = Icon;
}
return icons;
});
jest.mock('@/lib/constants/design-system', () => ({
DESIGN_SYSTEM: {
animation: {
duration: { fast: '0.2s', normal: '0.4s', slow: '0.6s', slower: '0.8s', countUp: '2s' },
easing: { smooth: 'cubic-bezier(0.4, 0, 0.2, 1)', bounce: 'cubic-bezier(0.34, 1.56, 0.64, 1)', easeOut: 'cubic-bezier(0, 0, 0.2, 1)', easeInOut: 'cubic-bezier(0.4, 0, 0.6, 1)' },
delay: { stagger: 0.08, section: 0.15 },
},
spacing: {
section: { py: 'py-20 lg:py-28', pyCompact: 'py-16 lg:py-20' },
container: { default: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8', narrow: 'max-w-5xl mx-auto px-4 sm:px-6 lg:px-8', wide: 'max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8' },
grid: { gap: 'gap-6 lg:gap-8', gapSmall: 'gap-4 lg:gap-6' },
},
typography: {
hero: { title: 'text-4xl', subtitle: 'text-lg', description: 'text-base' },
section: { title: 'text-3xl', subtitle: 'text-lg', body: 'text-base' },
card: { title: 'text-lg', description: 'text-sm' },
},
effects: {
inkGlow: { border: 'conic-gradient(...)', glow: 'radial-gradient(...)', speed: '3s' },
hover: { translateY: '-4px', shadow: 'shadow-xl', transition: 'all 0.3s' },
scroll: {
fadeInUp: {
initial: { opacity: 0, y: 24 },
animate: { opacity: 1, y: 0 },
viewport: { once: true, margin: '-80px' },
transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] },
},
staggerChildren: { delay: 0.08 },
},
},
colors: {
brand: { primary: 'var(--color-brand)', light: 'var(--color-brand-bg)', lighter: 'rgba(var(--color-brand-rgb), 0.04)', gradient: 'linear-gradient(135deg, var(--color-brand) 0%, #99182d 100%)' },
neutral: { '50': 'var(--color-bg-primary)', '100': 'var(--color-bg-section)', '200': 'var(--color-border-primary)' },
ink: { light: 'rgba(0, 0, 0, 0.03)', medium: 'rgba(0, 0, 0, 0.06)' },
},
components: {
card: { base: 'rounded-2xl', hover: 'hover:-translate-y-1', padding: 'p-6' },
badge: { base: 'inline-flex', variants: { primary: 'bg-brand-soft', secondary: 'bg-bg-tertiary' } },
button: { primary: 'bg-brand', secondary: 'bg-white', ghost: 'bg-transparent' },
metric: { container: 'text-center', value: 'text-3xl', label: 'text-sm', description: 'text-xs' },
},
},
}));
// ─── Mock Data ───────────────────────────────────────────────────────────
const mockHeroTheme = {
id: 'erp',
name: 'ERP',
gradientFrom: '#f8f6f3',
gradientTo: '#f0ebe4',
gradientAngle: 135,
accentColor: '#1e3a5f',
accentBg: 'rgba(30, 58, 95, 0.06)',
texture: { type: 'grid' as const, opacity: 0.03 },
layout: 'left' as const,
badge: '企业套装',
};
const mockCenterTheme = {
...mockHeroTheme,
id: 'solution',
layout: 'center' as const,
badge: undefined,
};
const mockProduct = {
id: 'erp',
title: 'ERP 企业资源管理系统',
description: '企业资源管理系统',
image: '/images/erp.jpg',
category: 'Enterprise Software',
categoryId: 'enterprise' as const,
status: '已发布' as const,
overview: '覆盖企业全业务流程的数字化管理平台',
features: ['财务管理:总账、应收应付、资产管理', '供应链管理:采购、库存、物流'],
benefits: ['提升运营效率 300%', '降低人力成本 50%'],
process: ['需求调研', '方案设计'],
specs: ['支持 10 万并发', '99.9% 可用性'],
tags: ['ERP', '企业管理'],
heroThemeId: 'erp',
bundle: 'enterprise' as const,
caseStudies: [
{ client: '某制造企业', industry: '制造业', challenge: '库存管理混乱', solution: '实施ERP系统', result: '库存周转率提升200%' },
],
dataProofs: [
{ metric: '效率提升', value: '300%', description: '业务流程效率提升' },
],
certifications: [
{ name: 'ISO 27001', issuer: '国际认证', link: 'https://example.com' },
],
};
// ─── Tests ───────────────────────────────────────────────────────────────
describe('DetailHero', () => {
it('should render title and subtitle', async () => {
const { DetailHero } = await import('./detail-hero');
render(
<DetailHero
theme={mockHeroTheme}
title="ERP 系统"
subtitle="企业资源管理专家"
/>
);
expect(screen.getByText('ERP 系统')).toBeInTheDocument();
expect(screen.getByText('企业资源管理专家')).toBeInTheDocument();
});
it('should render badge from theme', async () => {
const { DetailHero } = await import('./detail-hero');
render(
<DetailHero
theme={mockHeroTheme}
title="ERP"
subtitle="ERP Description"
/>
);
expect(screen.getByText('企业套装')).toBeInTheDocument();
});
it('should render badge prop overriding theme badge', async () => {
const { DetailHero } = await import('./detail-hero');
render(
<DetailHero
theme={mockHeroTheme}
badge="新版"
title="ERP"
subtitle="ERP Description"
/>
);
expect(screen.getByText('新版')).toBeInTheDocument();
});
it('should render description when provided', async () => {
const { DetailHero } = await import('./detail-hero');
render(
<DetailHero
theme={mockHeroTheme}
title="ERP"
subtitle="Expert"
description="Detailed description here"
/>
);
expect(screen.getByText('Detailed description here')).toBeInTheDocument();
});
it('should not render description when not provided', async () => {
const { DetailHero } = await import('./detail-hero');
const { container } = render(
<DetailHero
theme={mockHeroTheme}
title="ERP"
subtitle="Expert"
/>
);
// Should still render subtitle but no extra description paragraph
expect(screen.getByText('Expert')).toBeInTheDocument();
// The description text should not be present
expect(container.querySelector('section')?.children.length).toBeGreaterThan(0);
});
it('should render primary and secondary actions', async () => {
const { DetailHero } = await import('./detail-hero');
render(
<DetailHero
theme={mockHeroTheme}
title="ERP"
subtitle="Expert"
primaryAction={{ label: '了解更多', href: '/products/erp' }}
secondaryAction={{ label: '预约演示', href: '/contact' }}
/>
);
expect(screen.getByText('了解更多')).toBeInTheDocument();
expect(screen.getByText('预约演示')).toBeInTheDocument();
});
it('should not render actions when not provided', async () => {
const { DetailHero } = await import('./detail-hero');
render(
<DetailHero
theme={mockHeroTheme}
title="ERP"
subtitle="Expert"
/>
);
expect(screen.queryByText('了解更多')).not.toBeInTheDocument();
});
it('should render no badge when theme has no badge and no badge prop', async () => {
const { DetailHero } = await import('./detail-hero');
render(
<DetailHero
theme={mockCenterTheme}
title="Solution"
subtitle="Desc"
/>
);
// Center theme has no badge - verify sparkles (badge icon) is not in document
expect(screen.queryByText('企业套装')).not.toBeInTheDocument();
});
it('should render with center layout', async () => {
const { DetailHero } = await import('./detail-hero');
const { container } = render(
<DetailHero
theme={mockCenterTheme}
title="Solution"
subtitle="Desc"
/>
);
// Center layout renders text-center class
const section = container.querySelector('section');
expect(section).toBeInTheDocument();
});
it('should use theme-aware bg token (not hard-coded bg-white) for light variant', async () => {
const { DetailHero } = await import('./detail-hero');
const { container } = render(
<DetailHero
theme={mockHeroTheme}
title="ERP"
subtitle="Expert"
/>
);
// 暗黑模式:浅色底色必须走 CSS 变量 token,dark 下才能随主题反色
const section = container.querySelector('section');
expect(section?.className).toContain('bg-bg-primary');
expect(section?.className).not.toContain('bg-white');
});
});
describe('ProductValueSection', () => {
it('should render product title and overview', async () => {
const { ProductValueSection } = await import('./detail-product-value');
render(<ProductValueSection product={mockProduct} />);
const heading = screen.getByRole('heading', { name: new RegExp(mockProduct.title) });
expect(heading).toHaveTextContent('核心能力');
expect(screen.getByText(mockProduct.overview)).toBeInTheDocument();
});
it('should render feature items', async () => {
const { ProductValueSection } = await import('./detail-product-value');
render(<ProductValueSection product={mockProduct} />);
expect(screen.getByText('产品功能')).toBeInTheDocument();
// Features are rendered via FeatureTags which splits on ":"
expect(screen.getByText('财务管理')).toBeInTheDocument();
});
it('should render benefit items', async () => {
const { ProductValueSection } = await import('./detail-product-value');
render(<ProductValueSection product={mockProduct} />);
expect(screen.getByText('核心优势')).toBeInTheDocument();
expect(screen.getByText('提升运营效率 300%')).toBeInTheDocument();
expect(screen.getByText('降低人力成本 50%')).toBeInTheDocument();
});
it('should render spec items', async () => {
const { ProductValueSection } = await import('./detail-product-value');
render(<ProductValueSection product={mockProduct} />);
expect(screen.getByText('技术规格')).toBeInTheDocument();
expect(screen.getByText('支持 10 万并发')).toBeInTheDocument();
});
it('should render data proofs when available', async () => {
const { ProductValueSection } = await import('./detail-product-value');
render(<ProductValueSection product={mockProduct} />);
expect(screen.getByText('以可量化的目标约束交付')).toBeInTheDocument();
});
it('should not render data proofs section when empty', async () => {
const { ProductValueSection } = await import('./detail-product-value');
const productWithoutData = { ...mockProduct, dataProofs: [] };
render(<ProductValueSection product={productWithoutData} />);
expect(screen.queryByText('以可量化的目标约束交付')).not.toBeInTheDocument();
});
it('指标计数动画由 effect 驱动并在卸载时取消排队帧(Q-6 回归)', async () => {
// Q-6:动画曾在 render 体内 setState + requestAnimationFrame——每帧 setState 又触发
// 重渲染并重启一条新动画链,且从不 cancelAnimationFrame;卸载后帧回调继续排队。
// 判别面:已排队的帧必须被逐一取消(sched === cancel),旧实现 sched>0 且 cancel=0 必红。
jest.useFakeTimers();
// faithful rAF 桩:cancel 必须真的撤销已排队的回调,否则「卸载后不再排队」无从判别
const pending = new Map<number, ReturnType<typeof setTimeout>>();
let handle = 0;
const sched = jest.fn((cb: FrameRequestCallback) => {
const id = ++handle;
pending.set(
id,
setTimeout(() => {
pending.delete(id);
cb(Date.now());
}, 16),
);
return id;
});
const cancel = jest.fn((id: number) => {
const t = pending.get(id);
if (t) clearTimeout(t);
pending.delete(id);
});
const prevRaf = global.requestAnimationFrame;
const prevCancel = global.cancelAnimationFrame;
global.requestAnimationFrame = sched as unknown as typeof requestAnimationFrame;
global.cancelAnimationFrame = cancel as unknown as typeof cancelAnimationFrame;
try {
const { ProductValueSection } = await import('./detail-product-value');
const { container, unmount } = render(<ProductValueSection product={mockProduct} />);
const card = container.querySelector('.tabular-nums') as HTMLElement;
expect(card.textContent).toBe('0');
await act(async () => {
jest.advanceTimersByTime(200);
});
// 动画真的在推进(中途值),而不是停在 0 或瞬间跳终值
expect(card.textContent).not.toBe('0');
expect(card.textContent).not.toBe('300%');
const scheduledBeforeUnmount = sched.mock.calls.length;
expect(scheduledBeforeUnmount).toBeGreaterThan(0);
unmount();
await act(async () => {
jest.advanceTimersByTime(3000);
});
// 泄漏判别:卸载后动画链必须死透——旧实现(render 期 rAF、从不 cancel)
// 在这里会继续排队每一帧;effect 版在 cleanup 里 cancel 掉最后一帧。
expect(sched.mock.calls.length).toBe(scheduledBeforeUnmount);
} finally {
global.requestAnimationFrame = prevRaf;
global.cancelAnimationFrame = prevCancel;
jest.useRealTimers();
}
});
});
describe('DetailTrustSection', () => {
it('should render nothing when all props are empty', async () => {
const { DetailTrustSection } = await import('./detail-trust-section');
const { container } = render(<DetailTrustSection />);
// Component returns null when no content
expect(container.innerHTML).toBe('');
});
it('should render data proofs', async () => {
const { DetailTrustSection } = await import('./detail-trust-section');
render(
<DetailTrustSection
dataProofs={mockProduct.dataProofs}
/>
);
expect(screen.getByText('目标口径')).toBeInTheDocument();
expect(screen.getByText(mockProduct.dataProofs[0]!.metric)).toBeInTheDocument();
// 未声明 basis 的 dataProofs 必须自动落最弱口径角注,不得裸奔
expect(screen.getByTestId('metrics-basis-note')).toHaveTextContent('目标口径,非既成结果');
});
it('should render case studies', async () => {
const { DetailTrustSection } = await import('./detail-trust-section');
render(
<DetailTrustSection
caseStudies={mockProduct.caseStudies}
/>
);
expect(screen.getByText('他们已经实现了转型突破')).toBeInTheDocument();
expect(screen.getByTestId('case-study-card')).toBeInTheDocument();
expect(screen.getByTestId('case-client')).toHaveTextContent('某制造企业');
});
it('should render certifications', async () => {
const { DetailTrustSection } = await import('./detail-trust-section');
render(
<DetailTrustSection
certifications={mockProduct.certifications}
/>
);
expect(screen.getByText('资质认证')).toBeInTheDocument();
expect(screen.getByTestId('certification-list')).toBeInTheDocument();
expect(screen.getByTestId('cert-name')).toHaveTextContent('ISO 27001');
});
it('should render all sections together', async () => {
const { DetailTrustSection } = await import('./detail-trust-section');
render(
<DetailTrustSection
caseStudies={mockProduct.caseStudies}
dataProofs={mockProduct.dataProofs}
certifications={mockProduct.certifications}
accentColor="#C41E3A"
/>
);
expect(screen.getByText('目标口径')).toBeInTheDocument();
expect(screen.getByText('他们已经实现了转型突破')).toBeInTheDocument();
expect(screen.getByText('资质认证')).toBeInTheDocument();
});
});
describe('DetailCTASection', () => {
it('should render default title and description', async () => {
const { DetailCTASection } = await import('./detail-cta-section');
render(
<DetailCTASection
theme={mockHeroTheme}
primaryAction={{ label: '联系我们', href: '/contact' }}
/>
);
expect(screen.getByText('准备好开始了吗?')).toBeInTheDocument();
expect(screen.getByText('联系我们,获取专属方案和报价')).toBeInTheDocument();
});
it('should render custom title and description', async () => {
const { DetailCTASection } = await import('./detail-cta-section');
render(
<DetailCTASection
theme={mockHeroTheme}
title="Custom CTA Title"
description="Custom CTA description"
primaryAction={{ label: '立即咨询', href: '/contact' }}
/>
);
expect(screen.getByText('Custom CTA Title')).toBeInTheDocument();
expect(screen.getByText('Custom CTA description')).toBeInTheDocument();
});
it('should render primary action with link', async () => {
const { DetailCTASection } = await import('./detail-cta-section');
render(
<DetailCTASection
theme={mockHeroTheme}
primaryAction={{ label: '免费试用', href: '/trial' }}
/>
);
const link = screen.getByText('免费试用').closest('a');
expect(link).toHaveAttribute('href', '/trial');
});
it('should render secondary action when provided', async () => {
const { DetailCTASection } = await import('./detail-cta-section');
render(
<DetailCTASection
theme={mockHeroTheme}
primaryAction={{ label: '联系', href: '/contact' }}
secondaryAction={{ label: '下载资料', href: '/download' }}
/>
);
expect(screen.getByText('下载资料')).toBeInTheDocument();
const link = screen.getByText('下载资料').closest('a');
expect(link).toHaveAttribute('href', '/download');
});
it('should render free consultation badge', async () => {
const { DetailCTASection } = await import('./detail-cta-section');
render(
<DetailCTASection
theme={mockHeroTheme}
primaryAction={{ label: '联系', href: '/contact' }}
/>
);
expect(screen.getByText('免费咨询')).toBeInTheDocument();
});
it('should render SLA text', async () => {
const { DetailCTASection } = await import('./detail-cta-section');
render(
<DetailCTASection
theme={mockHeroTheme}
primaryAction={{ label: '联系', href: '/contact' }}
/>
);
expect(screen.getByText(/平均响应时间 < 2小时/)).toBeInTheDocument();
});
});
// 注:CrossRecommendGrid 相关测试已随组件删除(2026-08-31,组件生产 0 引用)