',
+ '/contact#%20%20',
+ ];
+
+ for (const path of specialPaths) {
+ await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(1000);
+
+ // 页面不应崩溃,body 应存在
+ const body = page.locator('body').first();
+ await expect(body).toBeVisible();
+ }
+ });
+
+ test('过长路径不导致页面异常', async ({ page }) => {
+ const longPath = '/a'.repeat(200);
+ await page.goto(longPath, { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(1500);
+
+ // 页面应正常渲染(404 页面或正常页面)
+ const body = page.locator('body').first();
+ await expect(body).toBeVisible();
+
+ const bodyText = await body.textContent();
+ expect(bodyText!.length).toBeGreaterThan(0);
+ });
+
+ test('查询参数不影响页面渲染', async ({ page }) => {
+ await page.goto('/?utm_source=test&utm_medium=email', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(1500);
+
+ // 首页应正常渲染
+ const header = page.locator('header').first();
+ await expect(header).toBeVisible();
+ });
+});
diff --git a/e2e/p6-missing-paths.spec.ts b/e2e/p6-missing-paths.spec.ts
new file mode 100644
index 0000000..7871d12
--- /dev/null
+++ b/e2e/p6-missing-paths.spec.ts
@@ -0,0 +1,273 @@
+import { test, expect, type Page } from '@playwright/test';
+
+/**
+ * P6: 缺失路径 E2E 测试补充
+ *
+ * 覆盖范围:
+ * 1. 滚动进度条(ScrollProgress)行为
+ * 2. 联系表单完整成功提交流程
+ * 3. 首页 → 产品列表 → 产品详情完整用户路径
+ * 4. 服务详情页滚动进度条
+ */
+
+test.setTimeout(60000);
+
+// ==================== 1. 滚动进度条测试 ====================
+test.describe('滚动进度条 - 全局组件行为', () => {
+
+ test('页面顶部时进度条缩放为 0', async ({ page }) => {
+ await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(1500);
+
+ const progressBar = page.locator('[data-testid="scroll-progress"]');
+ await expect(progressBar).toBeVisible();
+
+ const progressFill = progressBar.locator('> div').first();
+ const scale = await progressFill.evaluate((el) => {
+ const transform = (el as HTMLElement).style.transform;
+ const match = transform.match(/scaleX\(([\d.]+)\)/);
+ return match ? parseFloat(match[1]) : 0;
+ });
+ expect(scale).toBe(0);
+ });
+
+ test('滚动页面时进度条缩放增加', async ({ page }) => {
+ await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(1500);
+
+ const progressBar = page.locator('[data-testid="scroll-progress"]');
+ const progressFill = progressBar.locator('> div').first();
+
+ const getScale = () => progressFill.evaluate((el) => {
+ const transform = (el as HTMLElement).style.transform;
+ const match = transform.match(/scaleX\(([\d.]+)\)/);
+ return match ? parseFloat(match[1]) : 0;
+ });
+
+ // 先获取顶部缩放
+ const topScale = await getScale();
+
+ // 滚动到页面底部
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await page.waitForTimeout(500);
+
+ // 再次获取缩放
+ const bottomScale = await getScale();
+
+ expect(bottomScale).toBeGreaterThan(topScale);
+ });
+
+ test('进度条颜色使用品牌色变量', async ({ page }) => {
+ await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(1500);
+
+ const progressFill = page.locator('[data-testid="scroll-progress"] > div').first();
+ const background = await progressFill.evaluate((el) => window.getComputedStyle(el).backgroundColor);
+
+ // 应存在背景色(具体颜色取决于 CSS 变量解析)
+ expect(background).toBeTruthy();
+ expect(background).not.toBe('rgba(0, 0, 0, 0)');
+ });
+});
+
+// ==================== 2. 联系表单完整提交流程 ====================
+test.describe('联系表单 - 完整成功提交', () => {
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(2500);
+
+ // 关闭 Cookie 同意横幅,避免覆盖底部表单元素导致点击错位
+ const acceptAllButton = page.locator('button:has-text("接受所有")').first();
+ if (await acceptAllButton.count() > 0 && await acceptAllButton.isVisible()) {
+ await acceptAllButton.click();
+ await page.waitForTimeout(500);
+ }
+ });
+
+ async function fillContactForm(page: Page) {
+ await page.locator('[data-testid="name-input"]').fill('测试用户');
+ await page.locator('[data-testid="phone-input"]').fill('13800138000');
+ await page.locator('[data-testid="email-input"]').fill('test@example.com');
+ await page.locator('[data-testid="subject-input"]').fill('E2E 测试咨询');
+ await page.locator('[data-testid="message-input"]').fill('这是一条由 Playwright E2E 测试发送的消息。');
+ }
+
+ async function clickSubmitButton(page: Page) {
+ const submitButton = page.locator('[data-testid="submit-button"]');
+ // 按钮位于页面底部,先滚动到视口并等待布局稳定,避免点击被底部固定栏截获
+ await submitButton.scrollIntoViewIfNeeded();
+ await page.waitForTimeout(300);
+ // 使用原生 DOM click() 触发提交:Playwright 的 locator.click() 在该按钮上会被内部 flex 内容
+ // 的命中测试干扰,导致事件未正确冒泡到 form 的 React onSubmit 处理器。
+ await page.evaluate(() => {
+ const btn = document.querySelector('[data-testid="submit-button"]') as HTMLButtonElement | null;
+ btn?.click();
+ });
+ }
+
+ test('填写有效信息并提交成功', async ({ page }) => {
+ let routeHit = false;
+ // 拦截表单提交 API,返回成功响应
+ await page.route('/api/contact', async (route) => {
+ routeHit = true;
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ success: true, message: '发送成功' }),
+ });
+ });
+
+ await fillContactForm(page);
+ await clickSubmitButton(page);
+
+ // 等待成功状态出现
+ await page.waitForTimeout(1500);
+
+ console.log('Route hit in success test:', routeHit);
+
+ // 验证成功提示或成功状态存在
+ const bodyText = await page.locator('body').textContent();
+ const hasSuccessIndicator =
+ bodyText!.includes('消息已发送') ||
+ bodyText!.includes('感谢') ||
+ bodyText!.includes('发送成功');
+ expect(hasSuccessIndicator).toBe(true);
+ });
+
+ test('提交时显示加载状态', async ({ page }) => {
+ let routeHit = false;
+ // 延迟 API 响应以观察加载状态
+ await page.route('/api/contact', async (route) => {
+ routeHit = true;
+ await new Promise((resolve) => setTimeout(resolve, 800));
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ success: true, message: '发送成功' }),
+ });
+ });
+
+ await fillContactForm(page);
+
+ const submitButton = page.locator('[data-testid="submit-button"]');
+ await submitButton.scrollIntoViewIfNeeded();
+ await page.waitForTimeout(300);
+
+ // 隐藏可能覆盖底部元素的固定返回顶部按钮
+ await page.evaluate(() => {
+ const backToTop = document.querySelector('[aria-label="返回顶部"]') as HTMLElement | null;
+ if (backToTop) backToTop.style.display = 'none';
+ });
+
+ // 使用原生 DOM click() 触发提交,避免 Playwright 命中测试在按钮 flex 内容上失效
+ await page.evaluate(() => {
+ const btn = document.querySelector('[data-testid="submit-button"]') as HTMLButtonElement | null;
+ btn?.click();
+ });
+
+ await page.waitForTimeout(100);
+ console.log('disabled 100ms after click:', await submitButton.isDisabled(), 'routeHit:', routeHit);
+
+ // 提交后立即检查按钮是否被禁用
+ await expect(submitButton).toBeDisabled({ timeout: 5000 });
+ });
+});
+
+// ==================== 3. 完整用户路径 ====================
+test.describe('关键用户路径 - 首页 → 产品 → 详情', () => {
+
+ test('从首页导航到产品列表页', async ({ page }) => {
+ await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(2000);
+
+ // 桌面端导航中「产品」为下拉菜单,鼠标悬停展开后选择「查看全部产品」
+ const desktopNav = page.locator('[data-testid="desktop-navigation"]').first();
+ const productDropdown = desktopNav.locator('button:has-text("产品")').first();
+ await expect(productDropdown).toBeVisible({ timeout: 5000 });
+ await productDropdown.hover();
+ await page.waitForTimeout(300);
+
+ const viewAllLink = desktopNav.locator('a:has-text("查看全部产品")').first();
+ await expect(viewAllLink).toBeVisible({ timeout: 5000 });
+ // 鼠标悬停在链接上,防止下拉菜单因 mouseleave 关闭
+ await viewAllLink.hover();
+ await page.waitForTimeout(200);
+ await viewAllLink.click();
+
+ await page.waitForURL(/\/products/, { timeout: 10000 });
+ await expect(page).toHaveURL(/\/products/);
+
+ const pageTitle = page.locator('h1, h2').first();
+ await expect(pageTitle).toBeVisible();
+ });
+
+ test('从产品列表页进入产品详情页', async ({ page }) => {
+ await page.goto('/products', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(2000);
+
+ const productLinks = page.locator('a[href*="/products/"]:not([href$="/products"])');
+ const linkCount = await productLinks.count();
+ expect(linkCount).toBeGreaterThan(0);
+
+ const firstProduct = productLinks.first();
+ await firstProduct.click();
+
+ await page.waitForURL(/\/products\/.+/, { timeout: 10000 });
+ await expect(page).toHaveURL(/\/products\/.+/);
+
+ // 详情页应包含返回按钮或产品标题
+ const detailContent = page.locator('main').first();
+ await expect(detailContent).toBeVisible();
+ const detailText = await detailContent.textContent();
+ expect(detailText!.length).toBeGreaterThan(100);
+ });
+
+ test('从产品详情页可跳转回产品列表', async ({ page }) => {
+ await page.goto('/products/erp', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(2000);
+
+ // 查找返回或面包屑链接
+ const backLink = page.locator('a[href="/products"], a:has-text("返回")').first();
+ if (await backLink.count() > 0 && await backLink.isVisible()) {
+ await backLink.click();
+ await page.waitForURL(/\/products/, { timeout: 10000 });
+ await expect(page).toHaveURL(/\/products/);
+ }
+ });
+});
+
+// ==================== 4. 服务详情页滚动进度条 ====================
+test.describe('服务详情页 - 滚动进度条', () => {
+
+ test('服务详情页存在滚动进度条', async ({ page }) => {
+ await page.goto('/services', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(2000);
+
+ const serviceLinks = page.locator('a[href*="/services/"]:not([href$="/services"])');
+ if (await serviceLinks.count() > 0) {
+ await serviceLinks.first().click();
+ await page.waitForURL(/\/services\/.+/, { timeout: 10000 });
+ } else {
+ await page.goto('/services/digital-transformation', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForTimeout(2000);
+ }
+
+ // 服务详情页应渲染 ScrollProgress
+ const progressBar = page.locator('[data-testid="scroll-progress"]');
+ await expect(progressBar).toBeVisible();
+
+ const progressFill = progressBar.locator('> div').first();
+
+ // 滚动后缩放应变化
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await page.waitForTimeout(500);
+
+ const scale = await progressFill.evaluate((el) => {
+ const transform = (el as HTMLElement).style.transform;
+ const match = transform.match(/scaleX\(([\d.]+)\)/);
+ return match ? parseFloat(match[1]) : 0;
+ });
+ expect(scale).toBeGreaterThan(0);
+ });
+});
diff --git a/e2e/playwright.local.config.ts b/e2e/playwright.local.config.ts
new file mode 100644
index 0000000..80ebc93
--- /dev/null
+++ b/e2e/playwright.local.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: 0,
+ workers: 1,
+ reporter: [['list', { printSteps: true }]],
+ use: {
+ baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ ignoreHTTPSErrors: true,
+ actionTimeout: 15000,
+ navigationTimeout: 30000,
+ },
+ expect: {
+ timeout: 15000,
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+});
diff --git a/src/app/(marketing)/contact/contact-content-v3.test.tsx b/src/app/(marketing)/contact/contact-content-v3.test.tsx
new file mode 100644
index 0000000..5a5fcf5
--- /dev/null
+++ b/src/app/(marketing)/contact/contact-content-v3.test.tsx
@@ -0,0 +1,436 @@
+import { describe, it, expect, jest, beforeEach } from '@jest/globals';
+import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+// ─── Mocks ───────────────────────────────────────────────────────────────
+
+jest.mock('framer-motion', () => ({
+ motion: {
+ div: ({ children, initial, animate, whileInView, viewport, className, style, ...props }: any) => (
+
+ {children}
+
+ ),
+ p: ({ children, className, ...props }: any) => (
+ {children}
+ ),
+ h1: ({ children, className, ...props }: any) => (
+ {children}
+ ),
+ span: ({ children, className, ...props }: any) => (
+ {children}
+ ),
+ },
+ useInView: jest.fn(() => true),
+ AnimatePresence: ({ children }: any) => <>{children}>,
+}));
+
+jest.mock('next/navigation', () => ({
+ useSearchParams: jest.fn(() => new URLSearchParams()),
+}));
+
+jest.mock('@/lib/analytics', () => ({
+ trackContactForm: jest.fn(),
+ trackConversion: jest.fn(),
+}));
+
+jest.mock('@/components/ui/button', () => ({
+ Button: ({ children, type, disabled, className, ...props }: any) => (
+
+ ),
+}));
+
+jest.mock('@/components/ui/labeled-input', () => ({
+ LabeledInput: ({ label, error, id, placeholder, required, value, onChange, onBlur, name, type, 'data-testid': dataTestId, ...props }: any) => (
+
+ {label && }
+
+ {error && {error}}
+
+ ),
+}));
+
+jest.mock('@/components/ui/labeled-textarea', () => ({
+ LabeledTextarea: ({ label, error, id, placeholder, required, value, onChange, onBlur, name, rows, 'data-testid': dataTestId, ...props }: any) => (
+
+ {label && }
+
+ {error && {error}}
+
+ ),
+}));
+
+jest.mock('@/components/ui/toast', () => ({
+ Toast: ({ message, type, onClose, ...props }: any) => (
+
+ {message}
+
+
+ ),
+}));
+
+jest.mock('@/components/seo/structured-data', () => ({
+ BreadcrumbSchema: () => null,
+}));
+
+jest.mock('@/components/ui/tooltip', () => ({
+ LegacyTooltip: ({ children, content }: any) => (
+ {children}
+ ),
+}));
+
+jest.mock('sonner', () => ({
+ toast: {
+ success: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+ },
+}));
+
+jest.mock('lucide-react', () => {
+ const mockIcon = (name: string) => {
+ const Icon = (props: any) => ;
+ Icon.displayName = name;
+ return Icon;
+ };
+ return {
+ Mail: mockIcon('mail'),
+ MapPin: mockIcon('map-pin'),
+ Send: mockIcon('send'),
+ Loader2: mockIcon('loader2'),
+ Clock: mockIcon('clock'),
+ HeadphonesIcon: mockIcon('headphones'),
+ CheckCircle2: mockIcon('check-circle2'),
+ HelpCircle: mockIcon('help-circle'),
+ ArrowRight: mockIcon('arrow-right'),
+ };
+});
+
+// ─── Mock Data ────────────────────────────────────────────────────────────
+
+const mockContactData = {
+ heroSubtitle: 'GET IN TOUCH',
+ heroTitle: '让我们\n开启对话',
+ heroDescription: '无论您有任何疑问或需求,我们的团队都会在24小时内回复。',
+ contactTitle: '联系方式',
+ emailLabel: '电子邮箱',
+ addressLabel: '公司地址',
+ email: 'contact@novalon.cn',
+ address: '成都市高新区天府大道中段',
+ workHoursTitle: '工作时间',
+ dayLabel: '周一至周五',
+ hours: '09:00 — 18:00',
+ commitmentTitle: '我们的承诺',
+ commitmentItems: [
+ '24小时内回复您的咨询',
+ '免费提供初步方案评估',
+ '严格保密您的业务信息',
+ ],
+ formTitle: '发送消息',
+ successTitle: '消息已发送',
+ successMessage: '感谢您的留言,我们会尽快与您联系。',
+ submitLabel: '发送消息',
+ submittingLabel: '发送中...',
+ ctaSubtitle: 'Ready to Start',
+ ctaTitle: '准备好开启数字化转型了吗?',
+ ctaDescription: '立即预约免费咨询,我们的专家团队将为您量身定制最佳方案。',
+ ctaPrimaryLabel: '预约免费咨询',
+ ctaPrimaryHref: '/contact',
+ ctaSecondaryLabel: '了解更多',
+ ctaSecondaryHref: '/solutions',
+};
+
+// ─── Helpers ──────────────────────────────────────────────────────────────
+
+function getSubmitButton(): HTMLButtonElement {
+ // "发送消息"同时出现在 form title 和 button label 中,需用 getAllByText 筛选
+ const elements = screen.getAllByText('发送消息');
+ const btnEl = elements.find(el => el.closest('button'));
+ return (btnEl?.closest('button') || elements[0]?.closest('button')) as HTMLButtonElement;
+}
+
+function renderContactContent(data: Record = mockContactData) {
+ return import('./contact-content-v3').then((mod) => {
+ const Component = mod.default;
+ return render();
+ });
+}
+
+function fillFormFields() {
+ const nameInput = screen.getByTestId('name-input') as HTMLInputElement;
+ const phoneInput = screen.getByTestId('phone-input') as HTMLInputElement;
+ const emailInput = screen.getByTestId('email-input') as HTMLInputElement;
+ const subjectInput = screen.getByTestId('subject-input') as HTMLInputElement;
+ const messageInput = screen.getByTestId('message-input') as HTMLTextAreaElement;
+
+ fireEvent.change(nameInput, { target: { value: '张三' } });
+ fireEvent.change(phoneInput, { target: { value: '13800138000' } });
+ fireEvent.change(emailInput, { target: { value: 'zhangsan@example.com' } });
+ fireEvent.change(subjectInput, { target: { value: '咨询产品方案' } });
+ fireEvent.change(messageInput, { target: { value: '您好,我想了解贵公司的ERP产品和CRM产品,请提供详细方案和报价。' } });
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────
+
+describe('ContactContentV3 - 联系表单集成测试', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // 模拟全局 fetch
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ success: 'true' }),
+ } as Response)
+ ) as any;
+
+ // jsdom 默认不实现 scrollIntoView
+ Element.prototype.scrollIntoView = jest.fn();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('should render hero section with subtitle and title', async () => {
+ await renderContactContent();
+ expect(screen.getByText('GET IN TOUCH')).toBeInTheDocument();
+ expect(screen.getByText('开启对话')).toBeInTheDocument();
+ });
+
+ it('should render hero description', async () => {
+ await renderContactContent();
+ expect(screen.getByText(/无论您有任何疑问或需求/)).toBeInTheDocument();
+ });
+
+ it('should render contact info section', async () => {
+ await renderContactContent();
+ expect(screen.getByText('联系方式')).toBeInTheDocument();
+ expect(screen.getByText('contact@novalon.cn')).toBeInTheDocument();
+ expect(screen.getByText('成都市高新区天府大道中段')).toBeInTheDocument();
+ });
+
+ it('should render work hours section', async () => {
+ await renderContactContent();
+ expect(screen.getByText('工作时间')).toBeInTheDocument();
+ expect(screen.getByText('周一至周五')).toBeInTheDocument();
+ expect(screen.getByText('09:00 — 18:00')).toBeInTheDocument();
+ });
+
+ it('should render commitment items', async () => {
+ await renderContactContent();
+ expect(screen.getByText('24小时内回复您的咨询')).toBeInTheDocument();
+ expect(screen.getByText('免费提供初步方案评估')).toBeInTheDocument();
+ expect(screen.getByText('严格保密您的业务信息')).toBeInTheDocument();
+ });
+
+ it('should render form section with title', async () => {
+ await renderContactContent();
+ // "发送消息"同时出现在 form title 和 button 上
+ expect(screen.getAllByText('发送消息').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should render all form fields', async () => {
+ await renderContactContent();
+ expect(screen.getByTestId('name-input')).toBeInTheDocument();
+ expect(screen.getByTestId('phone-input')).toBeInTheDocument();
+ expect(screen.getByTestId('email-input')).toBeInTheDocument();
+ expect(screen.getByTestId('subject-input')).toBeInTheDocument();
+ expect(screen.getByTestId('message-input')).toBeInTheDocument();
+ });
+
+ it('should render submit button', async () => {
+ await renderContactContent();
+ // "发送消息"同时出现在 form title 和 button 上,用 getAllByText
+ const elements = screen.getAllByText('发送消息');
+ const buttonElements = elements.filter((el) => el.closest('button'));
+ expect(buttonElements.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should render CTA section', async () => {
+ await renderContactContent();
+ expect(screen.getByText('准备好开启数字化转型了吗?')).toBeInTheDocument();
+ expect(screen.getByText('预约免费咨询')).toBeInTheDocument();
+ expect(screen.getByText('了解更多')).toBeInTheDocument();
+ });
+
+ it('should render email link with correct href', async () => {
+ await renderContactContent();
+ const emailLink = screen.getByTestId('email-link');
+ expect(emailLink).toHaveAttribute('href', 'mailto:contact@novalon.cn');
+ });
+ });
+
+ describe('Empty State', () => {
+ it('should show empty state when no data is provided', async () => {
+ await renderContactContent({});
+ expect(screen.getByText('内容暂未发布')).toBeInTheDocument();
+ });
+
+ it('should show empty state when data is null-like', async () => {
+ await renderContactContent({} as any);
+ expect(screen.getByText('内容暂未发布')).toBeInTheDocument();
+ });
+ });
+
+ describe('Form Validation', () => {
+ it('should show validation errors when submitting empty form', async () => {
+ await renderContactContent();
+ const submitButton = getSubmitButton();
+ await act(async () => { submitButton.click(); });
+
+ // Toast should show with error count
+ await waitFor(() => {
+ expect(screen.getByTestId('toast')).toBeInTheDocument();
+ });
+ const toastMessage = screen.getByTestId('toast-message');
+ expect(toastMessage.textContent).toContain('请检查表单');
+ });
+
+ it('should show error for invalid phone number on blur', async () => {
+ await renderContactContent();
+ const phoneInput = screen.getByTestId('phone-input') as HTMLInputElement;
+
+ fireEvent.change(phoneInput, { target: { value: '12345' } });
+ fireEvent.blur(phoneInput, { target: { value: '12345' } });
+
+ // Validation error should be shown
+ const errorElements = screen.getAllByTestId('input-error');
+ const phoneError = errorElements.find((el) => el.textContent?.includes('手机号码'));
+ expect(phoneError).toBeTruthy();
+ });
+
+ it('should show error for invalid email on blur', async () => {
+ await renderContactContent();
+ const emailInput = screen.getByTestId('email-input') as HTMLInputElement;
+
+ fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
+ fireEvent.blur(emailInput, { target: { value: 'invalid-email' } });
+
+ const errorElements = screen.getAllByTestId('input-error');
+ const emailError = errorElements.find((el) => el.textContent?.includes('邮箱'));
+ expect(emailError).toBeTruthy();
+ });
+ });
+
+ describe('Form Submission', () => {
+ it('should submit form successfully and show success state', async () => {
+ // Mock fetch to return success
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ success: 'true' }),
+ } as Response)
+ ) as any;
+
+ await renderContactContent();
+
+ fillFormFields();
+
+ const submitButton = getSubmitButton();
+ await act(async () => { submitButton.click(); });
+
+ // Should show success state
+ await waitFor(() => {
+ expect(screen.getByText('消息已发送')).toBeInTheDocument();
+ });
+ // 成功消息同时出现在 form 成功状态和 toast 中,用 getAllByText
+ expect(screen.getAllByText('感谢您的留言,我们会尽快与您联系。').length).toBeGreaterThanOrEqual(1);
+
+ // Should have called analytics
+ const { trackContactForm, trackConversion } = await import('@/lib/analytics');
+ expect(trackContactForm).toHaveBeenCalled();
+ expect(trackConversion).toHaveBeenCalledWith('contact_form_submission');
+ });
+
+ it('should show error toast when API returns error', async () => {
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: false,
+ json: () => Promise.resolve({ message: '服务器错误,请稍后重试' }),
+ } as Response)
+ ) as any;
+
+ await renderContactContent();
+
+ fillFormFields();
+
+ const submitButton = getSubmitButton();
+ await act(async () => { submitButton.click(); });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('toast')).toBeInTheDocument();
+ });
+ const toastMessage = screen.getByTestId('toast-message');
+ expect(toastMessage.textContent).toContain('服务器错误');
+ });
+
+ it('should show error toast on network failure', async () => {
+ global.fetch = jest.fn(() => Promise.reject(new Error('Network error'))) as any;
+
+ await renderContactContent();
+
+ fillFormFields();
+
+ const submitButton = getSubmitButton();
+ await act(async () => { submitButton.click(); });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('toast')).toBeInTheDocument();
+ });
+ const toastMessage = screen.getByTestId('toast-message');
+ expect(toastMessage.textContent).toContain('网络错误');
+ });
+ });
+
+ describe('Accessibility', () => {
+ it('should have proper heading hierarchy', async () => {
+ await renderContactContent();
+ const h1 = screen.getByRole('heading', { level: 1 });
+ expect(h1).toBeInTheDocument();
+ // Should have section headings as h2 or h3
+ const headings = screen.getAllByRole('heading');
+ expect(headings.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('should have honeypot field for spam prevention', async () => {
+ await renderContactContent();
+ const honeypot = document.querySelector('input[aria-hidden="true"]');
+ expect(honeypot).toBeInTheDocument();
+ expect(honeypot).toHaveAttribute('tabIndex', '-1');
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/app/(marketing)/products/products-content-v3.test.tsx b/src/app/(marketing)/products/products-content-v3.test.tsx
new file mode 100644
index 0000000..62ed961
--- /dev/null
+++ b/src/app/(marketing)/products/products-content-v3.test.tsx
@@ -0,0 +1,304 @@
+import { describe, it, expect, jest } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import type { Product } from '@/lib/constants/products';
+
+// ─── Mocks ───────────────────────────────────────────────────────────────
+
+jest.mock('framer-motion', () => ({
+ motion: {
+ div: ({ children, initial, animate, whileHover, whileInView, viewport, className, style, onMouseMove, onMouseEnter, onMouseLeave, ref, ...props }: any) => (
+
+ {children}
+
+ ),
+ h1: ({ children, className, ...props }: any) => (
+ {children}
+ ),
+ p: ({ children, className, ...props }: any) => (
+ {children}
+ ),
+ span: ({ children, className, ...props }: any) => (
+ {children}
+ ),
+ a: ({ children, href, className, ...props }: any) => (
+ {children}
+ ),
+ },
+ useInView: jest.fn(() => true),
+ AnimatePresence: ({ children }: any) => <>{children}>,
+}));
+
+jest.mock('@/hooks/use-reduced-motion', () => ({
+ useReducedMotion: jest.fn(() => false),
+}));
+
+jest.mock('@/components/ui/scroll-reveal', () => ({
+ ScrollReveal: ({ children, className }: any) => (
+ {children}
+ ),
+ StaggerReveal: ({ children, className }: any) => (
+ {children}
+ ),
+}));
+
+jest.mock('@/components/ui/button', () => ({
+ Button: ({ children, className, ...props }: any) => (
+
+ ),
+}));
+
+jest.mock('@/components/ui/page-decoration', () => ({
+ SectionLabel: ({ children }: any) => {children},
+ EASE_OUT: [0.22, 1, 0.36, 1],
+}));
+
+jest.mock('lucide-react', () => {
+ const mockIcon = (name: string) => {
+ const Icon = (props: any) => ;
+ Icon.displayName = name;
+ return Icon;
+ };
+ return {
+ ArrowRight: mockIcon('arrow-right'),
+ Package: mockIcon('package'),
+ Cpu: mockIcon('cpu'),
+ BarChart3: mockIcon('barchart3'),
+ Users: mockIcon('users'),
+ Settings: mockIcon('settings'),
+ FileText: mockIcon('file-text'),
+ CheckCircle2: mockIcon('check-circle2'),
+ };
+});
+
+// ─── Mock Data ────────────────────────────────────────────────────────────
+
+const mockEnterpriseProducts: Product[] = [
+ {
+ id: 'erp',
+ title: '睿新ERP管理系统',
+ category: '企业旗舰系列',
+ categoryId: 'enterprise',
+ description: '覆盖企业财务、采购、库存、生产全流程',
+ overview: 'ERP 系统概述',
+ features: ['财务管理', '供应链管理'],
+ benefits: ['降本增效'],
+ tags: ['ERP', '核心系统'],
+ status: '已发布',
+ image: '/erp.png',
+ heroThemeId: 'erp',
+ bundle: 'enterprise',
+ scenario: '面向中大型制造与零售企业的核心运营系统',
+ metrics: [
+ { value: '10 万级', label: 'SKU 支撑' },
+ { value: '200+', label: '标准报表模板' },
+ { value: '99.5%', label: '数据准确率' },
+ ],
+ capabilities: ['财务核算', '采购管理', '生产计划', '库存控制'],
+ process: [],
+ specs: [],
+ caseStudies: [],
+ dataProofs: [],
+ certifications: [],
+ },
+ {
+ id: 'crm',
+ title: '睿新客户关系管理系统',
+ category: '企业旗舰系列',
+ categoryId: 'enterprise',
+ description: '全渠道客户管理平台',
+ overview: 'CRM 系统概述',
+ features: ['客户管理', '销售自动化'],
+ benefits: ['提升转化'],
+ tags: ['CRM', '客户管理'],
+ status: '已发布',
+ image: '/crm.png',
+ heroThemeId: 'crm',
+ bundle: 'enterprise',
+ scenario: '面向销售驱动型企业的一体化客户运营管理平台',
+ metrics: [
+ { value: '360°', label: '客户画像' },
+ { value: '3x', label: '转化率提升' },
+ { value: '<2h', label: '线索响应' },
+ ],
+ capabilities: ['线索管理', '商机跟进', '合同审批', '售后工单'],
+ process: [],
+ specs: [],
+ caseStudies: [],
+ dataProofs: [],
+ certifications: [],
+ },
+];
+
+const mockSpecializedProducts: Product[] = [
+ {
+ id: 'novavis',
+ title: 'NovaVis 数据可视化平台',
+ category: '专业产品',
+ categoryId: 'specialized',
+ description: '专业数据可视化工具',
+ overview: 'NovaVis 概述',
+ features: ['大屏展示', '报表设计'],
+ benefits: ['直观呈现'],
+ tags: ['数据可视化', 'BI'],
+ status: '研发中',
+ image: '/novavis.png',
+ heroThemeId: 'novavis',
+ bundle: 'standalone',
+ scenario: '面向执法机关的离线智能数据分析与资金关系图谱平台',
+ metrics: [
+ { value: '100万+', label: '秒级处理' },
+ { value: '<30秒', label: '分析耗时' },
+ { value: '20层+', label: '资金追踪' },
+ ],
+ capabilities: ['资金追踪', '关系图谱', 'AI 分析', '离线运行'],
+ process: [],
+ specs: [],
+ caseStudies: [],
+ dataProofs: [],
+ certifications: [],
+ },
+];
+
+const allMockProducts = [...mockEnterpriseProducts, ...mockSpecializedProducts];
+
+// ─── Tests ────────────────────────────────────────────────────────────────
+
+describe('ProductsContentV3 - 产品列表页集成测试', () => {
+ it('should render hero section with title', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ expect(screen.getByText('用自研产品组合')).toBeInTheDocument();
+ expect(screen.getByText('核心系统升级')).toBeInTheDocument();
+ });
+
+ it('should render hero description text', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ expect(screen.getByText(/从数据智能到业务协同/)).toBeInTheDocument();
+ });
+
+ it('should render enterprise products section', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ expect(screen.getAllByText('企业套装').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should render specialized products section', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ expect(screen.getAllByText('专业产品').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should render enterprise product cards with titles', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ // Each enterprise product title should appear 3 times: hero stats, product section, and suite combos section
+ expect(screen.getAllByText('睿新ERP管理系统').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('睿新客户关系管理系统').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should render specialized product cards with titles', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ expect(screen.getAllByText('NovaVis 数据可视化平台').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should render product capabilities on cards', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ expect(screen.getByText('财务核算')).toBeInTheDocument();
+ expect(screen.getByText('线索管理')).toBeInTheDocument();
+ });
+
+ it('should render product scenarios on cards', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ expect(screen.getAllByText('面向中大型制造与零售企业的核心运营系统').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('面向销售驱动型企业的一体化客户运营管理平台').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should link product cards to detail pages', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ const erpLinks = screen.getAllByRole('link');
+ const erpDetailLink = erpLinks.find((link) => link.getAttribute('href') === '/products/erp');
+ expect(erpDetailLink).toBeTruthy();
+ });
+
+ it('should render suite combos section', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ expect(screen.getByText('推荐产品组合')).toBeInTheDocument();
+ });
+
+ it('should render CTA section', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ expect(screen.getByText('预约免费咨询')).toBeInTheDocument();
+ expect(screen.getByText('浏览行业方案')).toBeInTheDocument();
+ });
+
+ it('should render stats section with product count', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ expect(screen.getByText('产品线')).toBeInTheDocument();
+ expect(screen.getByText('自研率')).toBeInTheDocument();
+ expect(screen.getByText('技术覆盖')).toBeInTheDocument();
+ });
+
+ it('should handle empty products list gracefully', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ // Hero section should still render
+ expect(screen.getByText('用自研产品组合')).toBeInTheDocument();
+
+ // The enterprise section should still render (even without products)
+ expect(screen.getAllByText('企业套装').length).toBeGreaterThanOrEqual(1);
+
+ // No product detail links should be present when products are empty
+ const links = screen.queryAllByRole('link');
+ const productLinks = links.filter((l) => l.getAttribute('href')?.startsWith('/products/'));
+ expect(productLinks.length).toBe(0);
+ });
+
+ it('should have proper CTA links pointing to contact and solutions', async () => {
+ const { default: ProductsContentV3 } = await import('./products-content-v3');
+ render();
+
+ const links = screen.getAllByRole('link');
+ const contactLinks = links.filter((l) => l.getAttribute('href') === '/contact');
+ const solutionsLinks = links.filter((l) => l.getAttribute('href') === '/solutions');
+ expect(contactLinks.length).toBeGreaterThanOrEqual(1);
+ expect(solutionsLinks.length).toBeGreaterThanOrEqual(1);
+ });
+});
\ No newline at end of file
diff --git a/src/components/detail/detail.test.tsx b/src/components/detail/detail.test.tsx
index 5d6f89b..d4d9eda 100644
--- a/src/components/detail/detail.test.tsx
+++ b/src/components/detail/detail.test.tsx
@@ -258,6 +258,7 @@ const mockProduct = {
specs: ['支持 10 万并发', '99.9% 可用性'],
tags: ['ERP', '企业管理'],
heroThemeId: 'erp',
+ bundle: 'enterprise' as const,
caseStudies: [
{ client: '某制造企业', industry: '制造业', challenge: '库存管理混乱', solution: '实施ERP系统', result: '库存周转率提升200%' },
],
@@ -403,8 +404,7 @@ describe('ProductValueSection', () => {
it('should render product title and overview', async () => {
const { ProductValueSection } = await import('./detail-product-value');
render();
- expect(screen.getByText('核心能力')).toBeInTheDocument();
- expect(screen.getByText(mockProduct.title)).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: /为企业打造的/ })).toHaveTextContent(mockProduct.title);
expect(screen.getByText(mockProduct.overview)).toBeInTheDocument();
});
@@ -434,7 +434,6 @@ describe('ProductValueSection', () => {
it('should render data proofs when available', async () => {
const { ProductValueSection } = await import('./detail-product-value');
render();
- expect(screen.getByText('数据说话')).toBeInTheDocument();
expect(screen.getByText('用真实效果证明价值')).toBeInTheDocument();
});
@@ -442,7 +441,7 @@ describe('ProductValueSection', () => {
const { ProductValueSection } = await import('./detail-product-value');
const productWithoutData = { ...mockProduct, dataProofs: [] };
render();
- expect(screen.queryByText('数据说话')).not.toBeInTheDocument();
+ expect(screen.queryByText('用真实效果证明价值')).not.toBeInTheDocument();
});
it('should render ink glow cards for features', async () => {
@@ -468,7 +467,6 @@ describe('DetailTrustSection', () => {
dataProofs={mockProduct.dataProofs}
/>
);
- expect(screen.getByText('数据说话')).toBeInTheDocument();
expect(screen.getByText('用真实效果证明价值')).toBeInTheDocument();
expect(screen.getByText(mockProduct.dataProofs[0]!.metric)).toBeInTheDocument();
});
@@ -480,7 +478,6 @@ describe('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('某制造企业');
@@ -508,8 +505,8 @@ describe('DetailTrustSection', () => {
accentColor="#C41E3A"
/>
);
- expect(screen.getByText('数据说话')).toBeInTheDocument();
- expect(screen.getByText('客户案例')).toBeInTheDocument();
+ expect(screen.getByText('用真实效果证明价值')).toBeInTheDocument();
+ expect(screen.getByText('他们已经实现了转型突破')).toBeInTheDocument();
expect(screen.getByText('资质认证')).toBeInTheDocument();
});
});
diff --git a/src/components/sections/sections.test.tsx b/src/components/sections/sections.test.tsx
index 2a4ecef..9b9e16e 100644
--- a/src/components/sections/sections.test.tsx
+++ b/src/components/sections/sections.test.tsx
@@ -248,11 +248,10 @@ describe('ProductCard', () => {
});
describe('ServiceCard', () => {
- it('should render number, title, description and link', async () => {
+ it('should render title, description and link', async () => {
const { ServiceCard } = await import('./service-card');
render(
{
const { ServiceCard } = await import('./service-card');
render(
{
expect(target).toBeTruthy();
});
- it('should apply custom color', async () => {
+ it('should accept optional number and color props for compatibility', async () => {
const { ServiceCard } = await import('./service-card');
render(
({
describe('ScrollProgress', () => {
beforeEach(() => {
jest.clearAllMocks();
- // Mock document height for predictable progress calculation
+ // Mock document/scroll geometry for predictable progress calculation
Object.defineProperty(document.documentElement, 'scrollHeight', {
value: 1000,
configurable: true,
@@ -19,6 +19,11 @@ describe('ScrollProgress', () => {
value: 500,
configurable: true,
});
+ Object.defineProperty(window, 'scrollY', {
+ value: 0,
+ configurable: true,
+ writable: true,
+ });
});
it('should always render the progress container', () => {
@@ -44,8 +49,10 @@ describe('ScrollProgress', () => {
const { container } = render();
const innerBar = (container.firstChild as HTMLElement).firstChild as HTMLElement;
expect(innerBar).toBeInTheDocument();
- // 默认 progress 为 0,宽度为 0%
- expect(innerBar.style.width).toBe('0%');
+ // 默认 progress 为 0,使用 transform scaleX 实现宽度变化
+ expect(innerBar.style.width).toBe('100%');
+ expect(innerBar.style.transform).toBe('scaleX(0)');
+ expect(innerBar.style.transformOrigin).toBe('left');
});
it('should apply default height of 2px', () => {
diff --git a/src/components/ui/scroll-progress.tsx b/src/components/ui/scroll-progress.tsx
index 6a282dc..53e7a34 100644
--- a/src/components/ui/scroll-progress.tsx
+++ b/src/components/ui/scroll-progress.tsx
@@ -48,13 +48,16 @@ export function ScrollProgress({
height: `${height}px`,
}}
aria-hidden="true"
+ data-testid="scroll-progress"
>
diff --git a/src/lib/cms/data-server.test.ts b/src/lib/cms/data-server.test.ts
new file mode 100644
index 0000000..bc0a711
--- /dev/null
+++ b/src/lib/cms/data-server.test.ts
@@ -0,0 +1,388 @@
+import { describe, it, expect, jest, beforeEach } from '@jest/globals';
+import '@testing-library/jest-dom';
+
+// ─── Mock react.cache(React 18 不支持,但 Next.js 14 需要)─────────────
+jest.mock('react', () => ({
+ ...(jest.requireActual('react') as Record),
+ cache: (fn: unknown) => fn,
+}));
+
+// ─── Mock @/lib/db 提供可控的 prisma 实例 ──────────────────────────────
+// 这样 data-server.ts 的 getPublishedItems 等函数会使用这个 mock prisma
+const mockContentItem = {
+ findMany: jest.fn<(args: unknown) => Promise>(),
+ findFirst: jest.fn<(args: unknown) => Promise>(),
+ findUnique: jest.fn<(args: unknown) => Promise>(),
+};
+
+const mockContentModel = {
+ findMany: jest.fn<(args: unknown) => Promise>(),
+};
+
+const mockContentZone = {
+ findMany: jest.fn<(args: unknown) => Promise>(),
+ findUnique: jest.fn<(args: unknown) => Promise>(),
+};
+
+jest.mock('@/lib/db', () => ({
+ prisma: {
+ contentItem: mockContentItem,
+ contentModel: mockContentModel,
+ contentZone: mockContentZone,
+ },
+}));
+
+// 取消全局 mock,使用真实 data-server 实现
+jest.unmock('@/lib/cms/data-server');
+
+// ─── 现在可以正常导入了 ────────────────────────────────────────────────────
+import * as dataServer from './data-server';
+
+// ─── Mock Prisma 数据 ─────────────────────────────────────────────────────
+
+const mockPrismaItem = {
+ id: 'item-1',
+ modelId: 'model-1',
+ modelCode: 'product',
+ title: '测试产品',
+ slug: 'test-product',
+ status: 'published',
+ data: JSON.stringify({
+ name: '测试产品',
+ description: '这是一个测试产品',
+ price: 100,
+ }),
+ version: 1,
+ sortOrder: 1,
+ activeVersion: 1,
+ createdBy: 'admin',
+ updatedBy: 'admin',
+ createdAt: new Date('2026-01-01'),
+ updatedAt: new Date('2026-01-01'),
+ publishedAt: new Date('2026-01-01'),
+};
+
+const mockPrismaModel = {
+ id: 'model-1',
+ code: 'product',
+ name: '产品',
+ description: '产品内容模型',
+ fields: JSON.stringify([
+ { name: 'name', type: 'text', label: '名称', required: true },
+ ]),
+ isPageType: true,
+ urlPattern: '/products/[slug]',
+ hasVersions: true,
+ hasDraft: true,
+ icon: 'package',
+ sortOrder: 1,
+ createdAt: new Date('2026-01-01'),
+ updatedAt: new Date('2026-01-01'),
+};
+
+const mockPrismaZone = {
+ id: 'zone-1',
+ code: 'home-hero',
+ name: '首页 Hero 区域',
+ description: '首页 Hero 轮播区域',
+ pageCode: 'home',
+ allowedModels: JSON.stringify(['hero-banner']),
+ items: JSON.stringify([
+ { itemId: 'hero-1', sortOrder: 1, variant: 'default' },
+ ]),
+ settings: JSON.stringify({
+ layout: 'carousel',
+ showTitle: true,
+ }),
+ createdAt: new Date('2026-01-01'),
+ updatedAt: new Date('2026-01-01'),
+};
+
+// ─── Tests ────────────────────────────────────────────────────────────────
+
+describe('data-server - CMS 数据访问层', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('getPublishedItems', () => {
+ it('should fetch published items for a given model code', async () => {
+ mockContentItem.findMany.mockResolvedValue([mockPrismaItem]);
+
+ const result = await dataServer.getPublishedItems('product');
+
+ expect(mockContentItem.findMany).toHaveBeenCalledWith({
+ where: { modelCode: 'product', status: 'published' },
+ orderBy: { sortOrder: 'asc' },
+ });
+ expect(result).toHaveLength(1);
+ expect(result[0]!.id).toBe('item-1');
+ expect(result[0]!.title).toBe('测试产品');
+ expect(result[0]!.data).toEqual({
+ name: '测试产品',
+ description: '这是一个测试产品',
+ price: 100,
+ });
+ });
+
+ it('should return empty array when no published items', async () => {
+ mockContentItem.findMany.mockResolvedValue([]);
+
+ const result = await dataServer.getPublishedItems('product');
+
+ expect(result).toEqual([]);
+ });
+
+ it('should only return published items (not draft)', async () => {
+ mockContentItem.findMany.mockResolvedValue([mockPrismaItem]);
+
+ const result = await dataServer.getPublishedItems('product');
+
+ expect(result).toHaveLength(1);
+ expect(result[0]!.status).toBe('published');
+
+ // 验证查询条件只包含 published
+ expect(mockContentItem.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ status: 'published',
+ }),
+ })
+ );
+ });
+ });
+
+ describe('getPublishedItemBySlug', () => {
+ it('should fetch a single published item by slug', async () => {
+ mockContentItem.findFirst.mockResolvedValue(mockPrismaItem);
+
+ const result = await dataServer.getPublishedItemBySlug('product', 'test-product');
+
+ expect(mockContentItem.findFirst).toHaveBeenCalledWith({
+ where: { modelCode: 'product', slug: 'test-product', status: 'published' },
+ });
+ expect(result).not.toBeNull();
+ expect(result!.title).toBe('测试产品');
+ });
+
+ it('should return null when item not found', async () => {
+ mockContentItem.findFirst.mockResolvedValue(null);
+
+ const result = await dataServer.getPublishedItemBySlug('product', 'non-existent');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return null for draft items', async () => {
+ mockContentItem.findFirst.mockResolvedValue(null);
+
+ const result = await dataServer.getPublishedItemBySlug('product', 'draft-product');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getItemById', () => {
+ it('should fetch an item by ID regardless of status', async () => {
+ mockContentItem.findUnique.mockResolvedValue(mockPrismaItem);
+
+ const result = await dataServer.getItemById('item-1');
+
+ expect(mockContentItem.findUnique).toHaveBeenCalledWith({
+ where: { id: 'item-1' },
+ });
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe('item-1');
+ });
+
+ it('should return null when item ID not found', async () => {
+ mockContentItem.findUnique.mockResolvedValue(null);
+
+ const result = await dataServer.getItemById('non-existent');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getPageZones', () => {
+ it('should fetch zones for a page code', async () => {
+ mockContentZone.findMany.mockResolvedValue([mockPrismaZone]);
+
+ const result = await dataServer.getPageZones('home');
+
+ expect(mockContentZone.findMany).toHaveBeenCalledWith({
+ where: { pageCode: 'home' },
+ });
+ expect(result).toHaveLength(1);
+ expect(result[0]!.code).toBe('home-hero');
+ expect(result[0]!.allowedModels).toEqual(['hero-banner']);
+ expect(result[0]!.items).toEqual([
+ { itemId: 'hero-1', sortOrder: 1, variant: 'default' },
+ ]);
+ expect(result[0]!.settings).toEqual({
+ layout: 'carousel',
+ showTitle: true,
+ });
+ });
+
+ it('should return empty array when no zones', async () => {
+ mockContentZone.findMany.mockResolvedValue([]);
+
+ const result = await dataServer.getPageZones('unknown');
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getZone', () => {
+ it('should fetch a single zone by code', async () => {
+ mockContentZone.findUnique.mockResolvedValue(mockPrismaZone);
+
+ const result = await dataServer.getZone('home-hero');
+
+ expect(mockContentZone.findUnique).toHaveBeenCalledWith({
+ where: { code: 'home-hero' },
+ });
+ expect(result).not.toBeNull();
+ expect(result!.name).toBe('首页 Hero 区域');
+ });
+
+ it('should return null when zone not found', async () => {
+ mockContentZone.findUnique.mockResolvedValue(null);
+
+ const result = await dataServer.getZone('non-existent');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getContentModels', () => {
+ it('should fetch all content models sorted by sortOrder', async () => {
+ mockContentModel.findMany.mockResolvedValue([mockPrismaModel]);
+
+ const result = await dataServer.getContentModels();
+
+ expect(mockContentModel.findMany).toHaveBeenCalledWith({
+ orderBy: { sortOrder: 'asc' },
+ });
+ expect(result).toHaveLength(1);
+ expect(result[0]!.code).toBe('product');
+ expect(result[0]!.fields).toEqual([
+ { name: 'name', type: 'text', label: '名称', required: true },
+ ]);
+ });
+ });
+
+ describe('getAllPublishedSlugs', () => {
+ it('should fetch all slugs for published items', async () => {
+ mockContentItem.findMany.mockResolvedValue([
+ { slug: 'test-product' },
+ { slug: 'another-product' },
+ ]);
+
+ const result = await dataServer.getAllPublishedSlugs('product');
+
+ expect(mockContentItem.findMany).toHaveBeenCalledWith({
+ where: { modelCode: 'product', status: 'published' },
+ select: { slug: true },
+ });
+ expect(result).toEqual([
+ { slug: 'test-product' },
+ { slug: 'another-product' },
+ ]);
+ });
+
+ it('should filter out null slugs', async () => {
+ mockContentItem.findMany.mockResolvedValue([
+ { slug: 'test-product' },
+ { slug: null },
+ ]);
+
+ const result = await dataServer.getAllPublishedSlugs('product');
+
+ expect(result).toEqual([{ slug: 'test-product' }]);
+ });
+ });
+
+ describe('Helper Functions', () => {
+ it('getCases should call getPublishedItems with case-study', async () => {
+ const spy = jest.spyOn(dataServer, 'getPublishedItems');
+ spy.mockResolvedValue([]);
+
+ await dataServer.getCases();
+ expect(spy).toHaveBeenCalledWith('case-study');
+ });
+
+ it('getNews should call getPublishedItems with news', async () => {
+ const spy = jest.spyOn(dataServer, 'getPublishedItems');
+ spy.mockResolvedValue([]);
+
+ await dataServer.getNews();
+ expect(spy).toHaveBeenCalledWith('news');
+ });
+
+ it('getServices should call getPublishedItems with service', async () => {
+ const spy = jest.spyOn(dataServer, 'getPublishedItems');
+ spy.mockResolvedValue([]);
+
+ await dataServer.getServices();
+ expect(spy).toHaveBeenCalledWith('service');
+ });
+
+ it('getProducts should call getPublishedItems with product', async () => {
+ const spy = jest.spyOn(dataServer, 'getPublishedItems');
+ spy.mockResolvedValue([]);
+
+ await dataServer.getProducts();
+ expect(spy).toHaveBeenCalledWith('product');
+ });
+
+ it('getSolutions should call getPublishedItems with solution', async () => {
+ const spy = jest.spyOn(dataServer, 'getPublishedItems');
+ spy.mockResolvedValue([]);
+
+ await dataServer.getSolutions();
+ expect(spy).toHaveBeenCalledWith('solution');
+ });
+
+ it('getStats should call getPublishedItems with stat-item', async () => {
+ const spy = jest.spyOn(dataServer, 'getPublishedItems');
+ spy.mockResolvedValue([]);
+
+ await dataServer.getStats();
+ expect(spy).toHaveBeenCalledWith('stat-item');
+ });
+
+ it('getHeroBanners should call getPublishedItems with hero-banner', async () => {
+ const spy = jest.spyOn(dataServer, 'getPublishedItems');
+ spy.mockResolvedValue([]);
+
+ await dataServer.getHeroBanners();
+ expect(spy).toHaveBeenCalledWith('hero-banner');
+ });
+
+ it('getCaseBySlug should call getPublishedItemBySlug with case-study', async () => {
+ const spy = jest.spyOn(dataServer, 'getPublishedItemBySlug');
+ spy.mockResolvedValue(null);
+
+ await dataServer.getCaseBySlug('test-case');
+ expect(spy).toHaveBeenCalledWith('case-study', 'test-case');
+ });
+
+ it('getNewsBySlug should call getPublishedItemBySlug with news', async () => {
+ const spy = jest.spyOn(dataServer, 'getPublishedItemBySlug');
+ spy.mockResolvedValue(null);
+
+ await dataServer.getNewsBySlug('test-news');
+ expect(spy).toHaveBeenCalledWith('news', 'test-news');
+ });
+
+ it('getHomePageZones should call getPageZones with home', async () => {
+ const spy = jest.spyOn(dataServer, 'getPageZones');
+ spy.mockResolvedValue([]);
+
+ await dataServer.getHomePageZones();
+ expect(spy).toHaveBeenCalledWith('home');
+ });
+ });
+});
\ No newline at end of file