test(core): 补充单元测试,修复 useCountUp 精度问题,新增项目文档
- 新增 hooks/components/lib 共 9 个测试文件,覆盖边界条件与异常路径 - 补充 animations.test.tsx 用例(RotatingBorder、CounterWithEffect 等) - 修复 useCountUp 结束时 toFixed 精度问题 - 调整 jest 覆盖率配置为渐进式阈值,收缩收集范围 - 新增 docs/lessons-learned.md(经验教训汇总)与 docs/troubleshooting.md(问题排查索引) - 更新 README.md 文档索引
This commit is contained in:
@@ -0,0 +1,647 @@
|
||||
import { describe, it, expect, jest } from '@jest/globals';
|
||||
import { render, screen } 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>
|
||||
),
|
||||
InkGlowCard: ({ children, className, active }: any) => (
|
||||
<div
|
||||
data-testid="ink-glow-card"
|
||||
data-active={active}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
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', 'BarChart3', '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',
|
||||
caseStudies: [
|
||||
{ client: '某制造企业', industry: '制造业', challenge: '库存管理混乱', solution: '实施ERP系统', result: '库存周转率提升200%' },
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: '效率提升', value: '300%', description: '业务流程效率提升' },
|
||||
],
|
||||
certifications: [
|
||||
{ name: 'ISO 27001', issuer: '国际认证', link: 'https://example.com' },
|
||||
],
|
||||
};
|
||||
|
||||
const mockCrossRefItems = [
|
||||
{ id: 's1', title: '智能制造方案', type: 'solution' as const, href: '/solutions/s1', reason: 'ERP 是核心组件' },
|
||||
{ id: 'p2', title: 'CRM 系统', type: 'product' as const, href: '/products/crm', reason: '常与 ERP 一起使用' },
|
||||
{ id: 'sv1', title: '软件定制开发', type: 'service' as const, href: '/services/sv1', reason: '配套服务' },
|
||||
];
|
||||
|
||||
// ─── 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProductValueSection', () => {
|
||||
it('should render product title and overview', async () => {
|
||||
const { ProductValueSection } = await import('./detail-product-value');
|
||||
render(<ProductValueSection product={mockProduct} />);
|
||||
expect(screen.getByText('核心能力')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockProduct.title)).toBeInTheDocument();
|
||||
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();
|
||||
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('should render ink glow cards for features', async () => {
|
||||
const { ProductValueSection } = await import('./detail-product-value');
|
||||
render(<ProductValueSection product={mockProduct} />);
|
||||
const glowCards = screen.getAllByTestId('ink-glow-card');
|
||||
expect(glowCards.length).toBeGreaterThanOrEqual(mockProduct.features.length);
|
||||
});
|
||||
});
|
||||
|
||||
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('用真实效果证明价值')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockProduct.dataProofs[0]!.metric)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render case studies', async () => {
|
||||
const { DetailTrustSection } = await import('./detail-trust-section');
|
||||
render(
|
||||
<DetailTrustSection
|
||||
caseStudies={mockProduct.caseStudies}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('客户案例')).toBeInTheDocument();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CrossRecommendGrid', () => {
|
||||
it('should render nothing when items is empty', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
const { container } = render(<CrossRecommendGrid items={[]} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should render default title', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} />);
|
||||
expect(screen.getByText('相关推荐')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom title', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} title="更多推荐" />);
|
||||
expect(screen.getByText('更多推荐')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all items', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} />);
|
||||
expect(screen.getByText('智能制造方案')).toBeInTheDocument();
|
||||
expect(screen.getByText('CRM 系统')).toBeInTheDocument();
|
||||
expect(screen.getByText('软件定制开发')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render reason for each item', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} />);
|
||||
expect(screen.getByText('ERP 是核心组件')).toBeInTheDocument();
|
||||
expect(screen.getByText('常与 ERP 一起使用')).toBeInTheDocument();
|
||||
expect(screen.getByText('配套服务')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correct type labels', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} />);
|
||||
// Type labels: product=产品, solution=方案, service=服务
|
||||
expect(screen.getByText('产品')).toBeInTheDocument();
|
||||
// There might be multiple "方案" texts in the page, so use getAllByText
|
||||
const solutionLabels = screen.getAllByText('方案');
|
||||
expect(solutionLabels.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('服务')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render links for items', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} />);
|
||||
const solutionLink = screen.getByText('智能制造方案').closest('a');
|
||||
expect(solutionLink).toHaveAttribute('href', '/solutions/s1');
|
||||
const productLink = screen.getByText('CRM 系统').closest('a');
|
||||
expect(productLink).toHaveAttribute('href', '/products/crm');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, it, expect, jest } from '@jest/globals';
|
||||
import { render, screen } 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, style, ref, ...props }: any) => (
|
||||
<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}
|
||||
{...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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
},
|
||||
useInView: jest.fn(() => true),
|
||||
AnimatePresence: ({ children }: any) => <>{children}</>,
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/use-reduced-motion', () => ({
|
||||
useReducedMotion: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/static-link', () => ({
|
||||
StaticLink: ({ children, href, className, onClick, ...props }: any) => (
|
||||
<a href={href} className={className} onClick={onClick} data-testid="static-link" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, size, variant, className, ...props }: any) => (
|
||||
<button className={`${className}${size ? ` btn-${size}` : ''}${variant ? ` btn-${variant}` : ''}`} data-testid="mock-button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/brand-visuals', () => ({
|
||||
BrandStamp: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="brand-stamp">{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/hero-ink-background', () => ({
|
||||
HeroInkBackground: () => <div data-testid="hero-ink-bg" />,
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href, className, ...props }: any) => (
|
||||
<a href={href} className={className} data-testid="next-link" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => {
|
||||
const mockIcon = (name: string) => {
|
||||
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} strokeWidth={props.strokeWidth} />;
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
ArrowRight: mockIcon('arrow-right'),
|
||||
MessageSquare: mockIcon('message-square'),
|
||||
Search: mockIcon('search'),
|
||||
Rocket: mockIcon('rocket'),
|
||||
Handshake: mockIcon('handshake'),
|
||||
Sparkles: mockIcon('sparkles'),
|
||||
MessageCircle: mockIcon('message-circle'),
|
||||
Clock: mockIcon('clock'),
|
||||
ShieldCheck: mockIcon('shield-check'),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SectionHeader', () => {
|
||||
it('should render label, title, and description', async () => {
|
||||
const { SectionHeader } = await import('./section-header');
|
||||
render(<SectionHeader label="TEST" title="Test Title" desc="Test description" />);
|
||||
expect(screen.getByText('TEST')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render highlighted text', async () => {
|
||||
const { SectionHeader } = await import('./section-header');
|
||||
render(<SectionHeader label="LABEL" title="Hello " highlight="World" />);
|
||||
expect(screen.getByText('World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply light mode', async () => {
|
||||
const { SectionHeader } = await import('./section-header');
|
||||
render(<SectionHeader label="TEST" title="Title" light />);
|
||||
const heading = screen.getByText('Title');
|
||||
// Light mode: white text (rendered as rgb)
|
||||
expect(heading.closest('h2')?.style.color).toBe('rgb(255, 255, 255)');
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { SectionHeader } = await import('./section-header');
|
||||
render(<SectionHeader label="T" title="T" className="my-class" />);
|
||||
// SectionHeader has multiple motion.div elements, find the first/outer one
|
||||
const elements = screen.getAllByTestId('motion-div');
|
||||
const outer = elements.find(el => el.className.includes('my-class'));
|
||||
expect(outer).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render without description when not provided', async () => {
|
||||
const { SectionHeader } = await import('./section-header');
|
||||
const { container } = render(<SectionHeader label="T" title="T" />);
|
||||
// There should be no <p> element for description
|
||||
const paragraphs = container.querySelectorAll('p');
|
||||
expect(paragraphs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatsBar', () => {
|
||||
const mockItems = [
|
||||
{ number: '500', unit: '+', label: '客户' },
|
||||
{ number: '99', unit: '%', label: '满意度', desc: '客户反馈评分' },
|
||||
];
|
||||
|
||||
it('should render stat items', async () => {
|
||||
const { StatsBar } = await import('./stats-bar');
|
||||
render(<StatsBar items={mockItems} />);
|
||||
expect(screen.getByText('客户')).toBeInTheDocument();
|
||||
expect(screen.getByText('满意度')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render description when provided', async () => {
|
||||
const { StatsBar } = await import('./stats-bar');
|
||||
render(<StatsBar items={mockItems} />);
|
||||
expect(screen.getByText('客户反馈评分')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { StatsBar } = await import('./stats-bar');
|
||||
const { container } = render(<StatsBar items={mockItems} className="stats-class" />);
|
||||
const grid = container.querySelector('.stats-class');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render in dark mode', async () => {
|
||||
const { StatsBar } = await import('./stats-bar');
|
||||
render(<StatsBar items={mockItems} dark />);
|
||||
const labels = screen.getAllByText('客户');
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProductCard', () => {
|
||||
it('should render title, description and icon', async () => {
|
||||
const { ProductCard } = await import('./product-card');
|
||||
render(
|
||||
<ProductCard
|
||||
icon={<span data-testid="custom-icon">★</span>}
|
||||
title="ERP 系统"
|
||||
description="企业资源管理系统"
|
||||
href="/products/erp"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('ERP 系统')).toBeInTheDocument();
|
||||
expect(screen.getByText('企业资源管理系统')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('static-link')).toHaveAttribute('href', '/products/erp');
|
||||
});
|
||||
|
||||
it('should render badge when provided', async () => {
|
||||
const { ProductCard } = await import('./product-card');
|
||||
render(
|
||||
<ProductCard
|
||||
icon={<span>★</span>}
|
||||
title="ERP"
|
||||
description="Desc"
|
||||
href="/products/erp"
|
||||
badge="新版本"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('新版本')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "了解更多" link', async () => {
|
||||
const { ProductCard } = await import('./product-card');
|
||||
render(
|
||||
<ProductCard
|
||||
icon={<span>★</span>}
|
||||
title="ERP"
|
||||
description="Desc"
|
||||
href="/products/erp"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('了解更多')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { ProductCard } = await import('./product-card');
|
||||
render(
|
||||
<ProductCard
|
||||
icon={<span>★</span>}
|
||||
title="ERP"
|
||||
description="Desc"
|
||||
href="/products/erp"
|
||||
className="card-class"
|
||||
/>
|
||||
);
|
||||
const link = screen.getByTestId('static-link');
|
||||
expect(link.className).toContain('card-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceCard', () => {
|
||||
it('should render number, title, description and link', async () => {
|
||||
const { ServiceCard } = await import('./service-card');
|
||||
render(
|
||||
<ServiceCard
|
||||
number="01"
|
||||
title="战略咨询"
|
||||
description="业务诊断与战略规划"
|
||||
href="/services/consulting"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('战略咨询')).toBeInTheDocument();
|
||||
expect(screen.getByText('业务诊断与战略规划')).toBeInTheDocument();
|
||||
expect(screen.getByText('了解详情')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('next-link')).toHaveAttribute('href', '/services/consulting');
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { ServiceCard } = await import('./service-card');
|
||||
render(
|
||||
<ServiceCard
|
||||
number="02"
|
||||
title="Data"
|
||||
description="Analysis"
|
||||
href="/services/data"
|
||||
className="service-class"
|
||||
/>
|
||||
);
|
||||
const elements = screen.getAllByTestId('motion-div');
|
||||
const target = elements.find(el => el.className.includes('service-class'));
|
||||
expect(target).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply custom color', async () => {
|
||||
const { ServiceCard } = await import('./service-card');
|
||||
render(
|
||||
<ServiceCard
|
||||
number="03"
|
||||
title="Blue"
|
||||
description="Service"
|
||||
href="/services/test"
|
||||
color="blue"
|
||||
/>
|
||||
);
|
||||
// ServiceCard has multiple motion.div elements, use getAllByTestId
|
||||
const elements = screen.getAllByTestId('motion-div');
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CTASection', () => {
|
||||
it('should render default content', async () => {
|
||||
const { CTASection } = await import('./cta-section');
|
||||
render(<CTASection />);
|
||||
expect(screen.getByText('一起聊聊您的数字化需求')).toBeInTheDocument();
|
||||
expect(screen.getByText('预约免费咨询')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom title and description', async () => {
|
||||
const { CTASection } = await import('./cta-section');
|
||||
render(
|
||||
<CTASection
|
||||
title="Custom Title"
|
||||
description="Custom description"
|
||||
primaryLabel="Get Started"
|
||||
primaryHref="/start"
|
||||
secondaryLabel="Learn More"
|
||||
secondaryHref="/about"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Custom Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Get Started')).toBeInTheDocument();
|
||||
expect(screen.getByText('Learn More')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper links', async () => {
|
||||
const { CTASection } = await import('./cta-section');
|
||||
render(<CTASection primaryHref="/contact" />);
|
||||
const links = screen.getAllByTestId('static-link');
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render brand stamp', async () => {
|
||||
const { CTASection } = await import('./cta-section');
|
||||
render(<CTASection />);
|
||||
expect(screen.getByTestId('brand-stamp')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroSectionV2', () => {
|
||||
it('should render hero heading and content', async () => {
|
||||
const { HeroSectionV2 } = await import('./hero-section-v2');
|
||||
render(<HeroSectionV2 />);
|
||||
expect(screen.getByText('企业数字化转型服务商')).toBeInTheDocument();
|
||||
expect(screen.getByText('免费获取定制方案')).toBeInTheDocument();
|
||||
expect(screen.getByText('探索产品')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render journey steps', async () => {
|
||||
const { HeroSectionV2 } = await import('./hero-section-v2');
|
||||
render(<HeroSectionV2 />);
|
||||
// These labels appear in both CAPABILITIES and JOURNEY_STEPS, use getAllByText
|
||||
expect(screen.getAllByText('需求沟通').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('方案诊断').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('敏捷交付').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('长期陪跑').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should render ink background', async () => {
|
||||
const { HeroSectionV2 } = await import('./hero-section-v2');
|
||||
render(<HeroSectionV2 />);
|
||||
expect(screen.getByTestId('hero-ink-bg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide CTA link to contact', async () => {
|
||||
const { HeroSectionV2 } = await import('./hero-section-v2');
|
||||
render(<HeroSectionV2 />);
|
||||
const links = screen.getAllByTestId('static-link');
|
||||
const contactLink = links.find(l => l.getAttribute('href') === '/contact');
|
||||
expect(contactLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user