Files
novalon-website/src/components/detail/detail.test.tsx
T
zhangxiang dcc2073e1e feat(theme): 暗黑模式 Phase 2 批次 3 — product-detail/standalone/detail-hero token 化
- product-detail-content-v3.tsx: 9 处 bg-white → bg-bg-primary
  (Hero / features / benefits / process / cases / certifications / CTA)
- standalone/[id]/client.tsx: 5 处 bg-white → bg-bg-primary
- detail-hero.tsx: 浅色 variant bg-white → bg-bg-primary(共享组件)
- detail.test.tsx: 新增常驻断言——light variant 必须用主题 token,禁止硬编码 bg-white

验证(evidence-first):
- type-check 0 / lint 0 errors
- jest 128 suites / 1624 passed(含新增断言)
- 视觉回归 22 passed(浅色基线零 diff)
- computed-style 一次性验证 4 passed: /products/erp + /products/crm 的 dark 反色
  (html/body/header → #0A0E14、section 层次自动反色)+ light 零深色残留;脚本已删

Phase 2 剩余批次: 批次 4 admin 全量(user 决策: admin 也参与)
2026-08-31 18:53:47 +08:00

585 lines
21 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 } 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();
});
});
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();
});
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 引用)