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:
张翔
2026-07-07 19:42:50 +08:00
parent 55381d7012
commit 38be4a19ef
15 changed files with 2907 additions and 6 deletions
+369
View File
@@ -0,0 +1,369 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
// Set GA ID before importing
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID = 'G-TEST123';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: jest.fn((key: string) => store[key] ?? null),
setItem: jest.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: jest.fn((key: string) => {
delete store[key];
}),
clear: jest.fn(() => {
store = {};
}),
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
describe('Analytics', () => {
let analytics: typeof import('./analytics');
beforeEach(async () => {
jest.clearAllMocks();
localStorageMock.clear();
// Reset gtag
delete (window as any).gtag;
analytics = await import('./analytics');
});
describe('getDefaultPreferences', () => {
it('should return default preferences', () => {
const prefs = analytics.getDefaultPreferences();
expect(prefs.necessary).toBe(true);
expect(prefs.analytics).toBe(true);
expect(prefs.marketing).toBe(false);
expect(prefs.functionality).toBe(true);
});
it('should return a copy, not the original', () => {
const prefs1 = analytics.getDefaultPreferences();
const prefs2 = analytics.getDefaultPreferences();
expect(prefs1).not.toBe(prefs2);
});
});
describe('getStoredPreferences', () => {
it('should return null when no preferences stored', () => {
const result = analytics.getStoredPreferences();
expect(result).toBeNull();
});
it('should return stored preferences', () => {
const testPrefs = { necessary: true, analytics: false, marketing: true, functionality: true };
localStorageMock.setItem('novalon-cookie-preferences', JSON.stringify(testPrefs));
const result = analytics.getStoredPreferences();
expect(result).toEqual(testPrefs);
});
it('should return null on malformed JSON', () => {
localStorageMock.setItem('novalon-cookie-preferences', 'not-json');
const result = analytics.getStoredPreferences();
expect(result).toBeNull();
});
});
describe('storePreferences', () => {
it('should store preferences to localStorage', () => {
const prefs = { necessary: true, analytics: true, marketing: false, functionality: true };
analytics.storePreferences(prefs);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'novalon-cookie-preferences',
JSON.stringify(prefs)
);
});
it('should throw and warn on localStorage error', () => {
localStorageMock.setItem.mockImplementationOnce(() => {
throw new Error('Storage full');
});
analytics.storePreferences({
necessary: true,
analytics: true,
marketing: false,
functionality: true,
});
expect(console.warn).toHaveBeenCalled();
});
});
describe('updateConsentDetailed', () => {
it('should call gtag when available', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.updateConsentDetailed({
necessary: true,
analytics: true,
marketing: false,
functionality: true,
});
expect(gtag).toHaveBeenCalledWith('consent', 'update', {
analytics_storage: 'granted',
ad_storage: 'denied',
functionality_storage: 'granted',
});
});
it('should not call gtag when not available', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.updateConsentDetailed({
necessary: true,
analytics: false,
marketing: true,
functionality: false,
});
expect(gtag).toHaveBeenCalledWith('consent', 'update', {
analytics_storage: 'denied',
ad_storage: 'granted',
functionality_storage: 'denied',
});
});
});
describe('trackEvent', () => {
it('should call gtag when available', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackEvent('click', 'engagement', 'button-1', 1);
expect(gtag).toHaveBeenCalledWith('event', 'click', {
event_category: 'engagement',
event_label: 'button-1',
event_value: 1,
send_to: 'G-TEST123',
});
});
it('should not call gtag when not available', () => {
analytics.trackEvent('click', 'engagement', 'button-1', 1);
// No gtag available, should not throw
});
});
describe('trackButtonClick', () => {
it('should call trackEvent with button name', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackButtonClick('submit-form', 'contact-page');
expect(gtag).toHaveBeenCalledWith('event', 'button_click', {
event_category: 'engagement',
event_label: 'submit-form',
event_value: undefined,
send_to: 'G-TEST123',
});
});
});
describe('trackError', () => {
it('should call gtag with error data when available', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackError('API_ERROR', 'Failed to fetch', true, { statusCode: 500 });
expect(gtag).toHaveBeenCalledWith('event', 'exception', {
description: '[API_ERROR] Failed to fetch',
fatal: 'true',
url: window.location.href,
timestamp: expect.any(String),
statusCode: 500,
send_to: 'G-TEST123',
});
});
it('should warn when gtag is not available', () => {
analytics.trackError('TEST', 'No gtag');
expect(console.warn).toHaveBeenCalled();
});
it('should set fatal to "false" string when not fatal', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackError('WARN', 'Minor issue', false);
const callArgs = gtag.mock.calls[0]?.[2] as Record<string, unknown>;
expect(callArgs?.fatal).toBe('false');
});
});
describe('trackPageView', () => {
it('should call gtag with page data', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackPageView('/products', 'Products');
expect(gtag).toHaveBeenCalledWith('config', 'G-TEST123', {
page_path: '/products',
page_title: 'Products',
});
});
it('should not throw when gtag is not available', () => {
analytics.trackPageView('/', 'Home');
});
});
describe('trackPerformance', () => {
it('should call gtag with performance data', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackPerformance('LCP', 2500, 'web_vitals');
expect(gtag).toHaveBeenCalledWith('event', 'LCP', {
event_category: 'web_vitals',
event_value: 2500,
value: 2500,
send_to: 'G-TEST123',
});
});
it('should round value to integer', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackPerformance('FID', 12.7);
const callArgs = gtag.mock.calls[0]?.[2] as Record<string, unknown>;
expect(callArgs?.value).toBe(13);
});
it('should use default category', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackPerformance('CLS', 0.1);
const callArgs = gtag.mock.calls[0]?.[2] as Record<string, unknown>;
expect(callArgs?.event_category).toBe('web_vitals');
});
});
describe('trackContactForm', () => {
it('should call gtag with form data object', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackContactForm({ name: 'Test', email: 'test@test.com', company: 'TestCorp' }, true);
expect(gtag).toHaveBeenCalledWith('event', 'form_submit', {
event_category: 'contact',
event_label: 'TestCorp',
event_value: 1,
send_to: 'G-TEST123',
});
});
it('should call gtag with string form data', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackContactForm('simple-form', false);
expect(gtag).toHaveBeenCalledWith('event', 'form_submit', {
event_category: 'contact',
event_label: 'simple-form',
event_value: 0,
send_to: 'G-TEST123',
});
});
it('should not track conversion when success is false', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackContactForm('failed-form', false);
// Only the form_submit event, not conversion
expect(gtag).toHaveBeenCalledTimes(1);
});
});
describe('trackConversion', () => {
it('should call gtag with conversion data', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackConversion('signup_complete', 5);
expect(gtag).toHaveBeenCalledWith('event', 'conversion', {
send_to: 'G-TEST123',
transaction_id: expect.any(String),
value: 5,
currency: 'CNY',
conversion_label: 'signup_complete',
});
});
it('should default value to 1', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackConversion('page_viewed');
const callArgs = gtag.mock.calls[0]?.[2] as Record<string, unknown>;
expect(callArgs?.value).toBe(1);
});
});
describe('trackOutboundLink', () => {
it('should call gtag with outbound link data', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackOutboundLink('https://example.com', 'Example');
expect(gtag).toHaveBeenCalledWith('event', 'outbound_click', {
event_category: 'engagement',
event_label: 'https://example.com',
event_value: undefined,
send_to: 'G-TEST123',
});
expect(gtag).toHaveBeenCalledWith('event', 'click', {
event_category: 'outbound',
event_label: 'https://example.com',
transport_type: 'beacon',
send_to: 'G-TEST123',
});
});
});
describe('trackScrollDepth', () => {
it('should call trackEvent with scroll percentage', () => {
const gtag = jest.fn();
(window as any).gtag = gtag;
analytics.trackScrollDepth(50);
expect(gtag).toHaveBeenCalledWith('event', 'scroll_50', {
event_category: 'engagement',
event_label: '50%',
event_value: undefined,
send_to: 'G-TEST123',
});
});
});
});
+86
View File
@@ -434,6 +434,19 @@ describe('Animation Components', () => {
expect(handleClick).toHaveBeenCalled();
});
it('should apply custom className', async () => {
const { MagneticButton } = await import('./animations');
render(<MagneticButton className="magnetic-class">Test</MagneticButton>);
const element = screen.getByText('Test').closest('.magnetic-class');
expect(element).toBeInTheDocument();
});
it('should apply custom strength', async () => {
const { MagneticButton } = await import('./animations');
render(<MagneticButton strength={0.5}>Test</MagneticButton>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
describe('BlurReveal', () => {
@@ -477,6 +490,79 @@ describe('Animation Components', () => {
expect(handleClick).toHaveBeenCalled();
});
it('should apply custom shimmerColor', async () => {
const { ShimmerButton } = await import('./animations');
render(<ShimmerButton shimmerColor="rgba(0,0,0,0.5)">Test</ShimmerButton>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
describe('RotatingBorder', () => {
it('should render children correctly', async () => {
const { RotatingBorder } = await import('./animations');
render(<RotatingBorder>Border Content</RotatingBorder>);
expect(screen.getByText('Border Content')).toBeInTheDocument();
});
it('should apply custom className', async () => {
const { RotatingBorder } = await import('./animations');
render(<RotatingBorder className="rotating-class">Test</RotatingBorder>);
const container = screen.getByText('Test').closest('.rotating-class');
expect(container).toBeInTheDocument();
});
it('should accept custom borderWidth', async () => {
const { RotatingBorder } = await import('./animations');
render(<RotatingBorder borderWidth={4}>Test</RotatingBorder>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('should accept custom colors', async () => {
const { RotatingBorder } = await import('./animations');
render(<RotatingBorder colors={['red', 'blue', 'green']}>Test</RotatingBorder>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('should accept custom duration', async () => {
const { RotatingBorder } = await import('./animations');
render(<RotatingBorder duration={8}>Test</RotatingBorder>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
describe('CounterWithEffect', () => {
it('should render with prefix and suffix', async () => {
const { CounterWithEffect } = await import('./animations');
render(<CounterWithEffect end={100} prefix="$" suffix="%" />);
expect(screen.getByText(/100|\$/)).toBeInTheDocument();
expect(screen.getByText(/%/)).toBeInTheDocument();
});
it('should apply custom className', async () => {
const { CounterWithEffect } = await import('./animations');
render(<CounterWithEffect end={100} className="counter-eff-class" />);
const element = screen.getByTestId('motion-span');
expect(element).toHaveClass('counter-eff-class');
});
it('should accept bounce effect', async () => {
const { CounterWithEffect } = await import('./animations');
render(<CounterWithEffect end={100} effect="bounce" />);
expect(screen.getByTestId('motion-span')).toBeInTheDocument();
});
it('should accept slide effect', async () => {
const { CounterWithEffect } = await import('./animations');
render(<CounterWithEffect end={100} effect="slide" />);
expect(screen.getByTestId('motion-span')).toBeInTheDocument();
});
it('should accept flip effect', async () => {
const { CounterWithEffect } = await import('./animations');
render(<CounterWithEffect end={100} effect="flip" />);
expect(screen.getByTestId('motion-span')).toBeInTheDocument();
});
});
});
+203
View File
@@ -0,0 +1,203 @@
import { describe, it, expect } from '@jest/globals';
import {
CASE_STUDIES,
CASE_INDUSTRIES,
getCaseBySlug,
getFeaturedCases,
getCasesByIndustry,
} from './cases';
describe('CASE_STUDIES', () => {
it('should have 6 case studies', () => {
expect(CASE_STUDIES.length).toBe(6);
});
it('should have required properties on each case', () => {
CASE_STUDIES.forEach((c) => {
expect(c).toHaveProperty('id');
expect(c).toHaveProperty('slug');
expect(c).toHaveProperty('client');
expect(c).toHaveProperty('industry');
expect(c).toHaveProperty('companySize');
expect(c).toHaveProperty('title');
expect(c).toHaveProperty('subtitle');
expect(c).toHaveProperty('challenge');
expect(c).toHaveProperty('solution');
expect(c).toHaveProperty('result');
expect(c).toHaveProperty('metrics');
expect(c).toHaveProperty('timeline');
expect(c).toHaveProperty('services');
expect(c).toHaveProperty('color');
});
});
it('should have unique slugs', () => {
const slugs = CASE_STUDIES.map((c) => c.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
it('should have unique ids', () => {
const ids = CASE_STUDIES.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('should have valid color values', () => {
const validColors = ['brand', 'blue', 'teal', 'amber', 'purple'];
CASE_STUDIES.forEach((c) => {
expect(validColors).toContain(c.color);
});
});
it('should have at least one metric per case', () => {
CASE_STUDIES.forEach((c) => {
expect(c.metrics.length).toBeGreaterThan(0);
});
});
it('should have at least one timeline phase per case', () => {
CASE_STUDIES.forEach((c) => {
expect(c.timeline.length).toBeGreaterThan(0);
});
});
it('should have at least one service per case', () => {
CASE_STUDIES.forEach((c) => {
expect(c.services.length).toBeGreaterThan(0);
});
});
it('should have valid metric structure', () => {
CASE_STUDIES.forEach((c) => {
c.metrics.forEach((m) => {
expect(m).toHaveProperty('value');
expect(m).toHaveProperty('label');
});
});
});
it('should have valid timeline structure', () => {
CASE_STUDIES.forEach((c) => {
c.timeline.forEach((t) => {
expect(t).toHaveProperty('phase');
expect(t).toHaveProperty('duration');
expect(t).toHaveProperty('description');
});
});
});
it('should have valid service structure', () => {
CASE_STUDIES.forEach((c) => {
c.services.forEach((s) => {
expect(s).toHaveProperty('id');
expect(s).toHaveProperty('title');
expect(s).toHaveProperty('description');
});
});
});
it('should have 3 featured cases', () => {
const featured = CASE_STUDIES.filter((c) => c.featured);
expect(featured.length).toBe(3);
});
});
describe('CASE_INDUSTRIES', () => {
it('should have 7 industries', () => {
expect(CASE_INDUSTRIES.length).toBe(7);
});
it('should have required properties', () => {
CASE_INDUSTRIES.forEach((ind) => {
expect(ind).toHaveProperty('id');
expect(ind).toHaveProperty('label');
});
});
it('should have "all" as first industry', () => {
expect(CASE_INDUSTRIES[0]?.id).toBe('all');
expect(CASE_INDUSTRIES[0]?.label).toBe('全部行业');
});
it('should have valid industry ids', () => {
const validIds = ['all', '制造业', '贸易零售', '医疗健康', '教育培训', '金融服务', '物流运输'];
CASE_INDUSTRIES.forEach((ind) => {
expect(validIds).toContain(ind.id);
});
});
});
describe('getCaseBySlug', () => {
it('should return the correct case by slug', () => {
const result = getCaseBySlug('manufacturing-erp-upgrade');
expect(result).toBeDefined();
expect(result?.id).toBe('case-001');
expect(result?.client).toBe('某上市制造企业');
});
it('should return undefined for non-existent slug', () => {
const result = getCaseBySlug('non-existent-slug');
expect(result).toBeUndefined();
});
it('should handle empty string', () => {
const result = getCaseBySlug('');
expect(result).toBeUndefined();
});
});
describe('getFeaturedCases', () => {
it('should return only featured cases', () => {
const result = getFeaturedCases();
expect(result.length).toBe(3);
result.forEach((c) => {
expect(c.featured).toBe(true);
});
});
it('should include manufacturing case', () => {
const result = getFeaturedCases();
const slugs = result.map((c) => c.slug);
expect(slugs).toContain('manufacturing-erp-upgrade');
});
it('should include retail case', () => {
const result = getFeaturedCases();
const slugs = result.map((c) => c.slug);
expect(slugs).toContain('retail-omnichannel');
});
it('should include healthcare case', () => {
const result = getFeaturedCases();
const slugs = result.map((c) => c.slug);
expect(slugs).toContain('healthcare-data-platform');
});
});
describe('getCasesByIndustry', () => {
it('should return all cases when industry is "all"', () => {
const result = getCasesByIndustry('all');
expect(result.length).toBe(6);
});
it('should filter by 制造业', () => {
const result = getCasesByIndustry('制造业');
expect(result.length).toBe(1);
expect(result[0]?.slug).toBe('manufacturing-erp-upgrade');
});
it('should filter by 医疗健康', () => {
const result = getCasesByIndustry('医疗健康');
expect(result.length).toBe(1);
expect(result[0]?.slug).toBe('healthcare-data-platform');
});
it('should return empty array for non-existent industry', () => {
const result = getCasesByIndustry('非存在行业');
expect(result.length).toBe(0);
});
it('should return empty array for empty string', () => {
const result = getCasesByIndustry('');
expect(result.length).toBe(0);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect } from '@jest/globals';
import {
getProductCrossRefs,
getSolutionCrossRefs,
getServiceCrossRefs,
} from './cross-references';
describe('getProductCrossRefs', () => {
it('should return related solutions for erp', () => {
const result = getProductCrossRefs('erp');
expect(result.length).toBeGreaterThan(0);
const solutionRefs = result.filter((r) => r.type === 'solution');
expect(solutionRefs.length).toBeGreaterThan(0);
solutionRefs.forEach((r) => {
expect(r).toHaveProperty('id');
expect(r).toHaveProperty('title');
expect(r).toHaveProperty('type', 'solution');
expect(r).toHaveProperty('href');
expect(r.href).toMatch(/^\/solutions\//);
expect(r).toHaveProperty('reason');
});
});
it('should return related products for erp', () => {
const result = getProductCrossRefs('erp');
const productRefs = result.filter((r) => r.type === 'product');
productRefs.forEach((r) => {
expect(r).toHaveProperty('id');
expect(r).toHaveProperty('title');
expect(r).toHaveProperty('type', 'product');
expect(r).toHaveProperty('href');
expect(r.href).toMatch(/^\/products\//);
expect(r).toHaveProperty('reason');
});
});
it('should return at most 3 product cross-refs', () => {
const result = getProductCrossRefs('erp');
const productRefs = result.filter((r) => r.type === 'product');
expect(productRefs.length).toBeLessThanOrEqual(3);
});
it('should return empty array for non-existent product', () => {
const result = getProductCrossRefs('non-existent');
expect(result).toEqual([]);
});
it('should return cross-refs for crm', () => {
const result = getProductCrossRefs('crm');
expect(result.length).toBeGreaterThan(0);
const solutionSlugs = result.filter((r) => r.type === 'solution').map((r) => r.id);
expect(solutionSlugs).toContain('retail');
});
});
describe('getSolutionCrossRefs', () => {
it('should return related products for manufacturing', () => {
const result = getSolutionCrossRefs('manufacturing');
expect(result.length).toBeGreaterThan(0);
result.forEach((r) => {
expect(r).toHaveProperty('id');
expect(r).toHaveProperty('title');
expect(r).toHaveProperty('type', 'product');
expect(r).toHaveProperty('href');
expect(r.href).toMatch(/^\/products\//);
expect(r).toHaveProperty('reason');
});
});
it('should include erp and bi for manufacturing', () => {
const result = getSolutionCrossRefs('manufacturing');
const productIds = result.map((r) => r.id);
expect(productIds).toContain('erp');
expect(productIds).toContain('bi');
});
it('should include crm and bi for retail', () => {
const result = getSolutionCrossRefs('retail');
const productIds = result.map((r) => r.id);
expect(productIds).toContain('crm');
expect(productIds).toContain('bi');
});
it('should return empty array for non-existent solution', () => {
const result = getSolutionCrossRefs('non-existent');
expect(result).toEqual([]);
});
});
describe('getServiceCrossRefs', () => {
it('should return related solutions for software service', () => {
const result = getServiceCrossRefs('software');
expect(result.length).toBeGreaterThan(0);
result.forEach((r) => {
expect(r).toHaveProperty('id');
expect(r).toHaveProperty('title');
expect(r).toHaveProperty('type', 'solution');
expect(r).toHaveProperty('href');
expect(r.href).toMatch(/^\/solutions\//);
expect(r).toHaveProperty('reason');
});
});
it('should include manufacturing and retail for software', () => {
const result = getServiceCrossRefs('software');
const solutionIds = result.map((r) => r.id);
expect(solutionIds).toContain('manufacturing');
expect(solutionIds).toContain('retail');
});
it('should return at most 3 solutions', () => {
const result = getServiceCrossRefs('solutions');
expect(result.length).toBeLessThanOrEqual(3);
});
it('should return empty array for non-existent service', () => {
const result = getServiceCrossRefs('non-existent');
expect(result).toEqual([]);
});
it('should return empty array for empty string', () => {
const result = getServiceCrossRefs('');
expect(result).toEqual([]);
});
});