From a6ea2cc72ce419d887db53b73c87d288b52a6bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E7=BF=94?= Date: Wed, 8 Jul 2026 14:47:07 +0800 Subject: [PATCH] test(e2e,unit): add navigation edge cases and component coverage - Add missing-paths E2E spec for desktop/mobile navigation and contrast - Add local Playwright config for dev server testing - Expand unit tests for CMS data server, products, contact and UI components - Adjust jest config for new test patterns --- config/test/jest.config.js | 78 +++- e2e/p5-edge-cases.spec.ts | 182 ++++++++ e2e/p6-missing-paths.spec.ts | 273 +++++++++++ e2e/playwright.local.config.ts | 27 ++ .../contact/contact-content-v3.test.tsx | 436 ++++++++++++++++++ .../products/products-content-v3.test.tsx | 304 ++++++++++++ src/components/detail/detail.test.tsx | 13 +- src/components/sections/sections.test.tsx | 6 +- src/components/ui/scroll-progress.test.tsx | 13 +- src/components/ui/scroll-progress.tsx | 7 +- src/lib/cms/data-server.test.ts | 388 ++++++++++++++++ 11 files changed, 1693 insertions(+), 34 deletions(-) create mode 100644 e2e/p5-edge-cases.spec.ts create mode 100644 e2e/p6-missing-paths.spec.ts create mode 100644 e2e/playwright.local.config.ts create mode 100644 src/app/(marketing)/contact/contact-content-v3.test.tsx create mode 100644 src/app/(marketing)/products/products-content-v3.test.tsx create mode 100644 src/lib/cms/data-server.test.ts diff --git a/config/test/jest.config.js b/config/test/jest.config.js index 292e797..92ecf02 100644 --- a/config/test/jest.config.js +++ b/config/test/jest.config.js @@ -17,32 +17,76 @@ module.exports = { '!src/**/*.stories.{ts,tsx}', '!src/**/__tests__/**', ], - // 覆盖率阈值采用渐进提升策略:初始值设低于当前实测值,每完成一个迭代任务后提升 - // 当前实测值: global(~17% stmts/~12% branches), hooks(~18%/~5%), lib(~33%/~10%), ui(~16%/~21%) + // 覆盖率阈值采用渐进提升策略:基于 Phase 1-5 实测值设定目录级阈值,避免全局平均掩盖核心模块缺口 + // 实测基准 (Phase 5): global(~33% stmts/~32% branches), hooks(~61%/~41%), lib(~45%/~43%), + // ui(~15%/~21%), layout(~59%/~48%), detail(~32%/~29%), sections(~31%/~35%), content(~29%/~5%), + // lib/cms(~67%/~83%), lib/constants(~94%/~81%) coverageThreshold: { global: { - branches: 10, + branches: 30, + functions: 25, + lines: 32, + statements: 30, + }, + './src/hooks/': { + branches: 38, + functions: 60, + lines: 60, + statements: 60, + }, + './src/lib/': { + branches: 40, + functions: 45, + lines: 42, + statements: 42, + }, + './src/components/ui/': { + branches: 18, functions: 10, lines: 15, statements: 14, }, - './src/hooks/': { - branches: 3, - functions: 15, - lines: 15, - statements: 14, + './src/components/layout/': { + branches: 45, + functions: 35, + lines: 60, + statements: 55, }, - './src/lib/': { - branches: 8, + './src/components/detail/': { + branches: 25, + functions: 22, + lines: 30, + statements: 30, + }, + './src/components/sections/': { + branches: 30, functions: 18, - lines: 28, - statements: 28, + lines: 30, + statements: 30, }, - './src/components/ui/': { - branches: 16, - functions: 9, - lines: 14, - statements: 13, + './src/components/content/': { + branches: 4, + functions: 4, + lines: 25, + statements: 25, + }, + './src/components/seo/': { + branches: 0, + functions: 20, + lines: 45, + statements: 45, + }, + './src/lib/cms/': { + branches: 80, + functions: 75, + lines: 60, + statements: 65, + }, + './src/lib/constants/': { + branches: 80, + functions: 55, + lines: 90, + statements: 90, }, }, coverageReporters: ['text', 'lcov', 'html', 'json'], diff --git a/e2e/p5-edge-cases.spec.ts b/e2e/p5-edge-cases.spec.ts new file mode 100644 index 0000000..c9e5f9e --- /dev/null +++ b/e2e/p5-edge-cases.spec.ts @@ -0,0 +1,182 @@ +import { test, expect } from '@playwright/test'; + +/** + * P5: 边缘情况与缺失路径 E2E 测试 + * + * 覆盖范围: + * 1. 404 未找到页面 + * 2. 全局错误边界 + * 3. 页面加载状态 + * 4. 无效路由与特殊字符 + */ + +test.setTimeout(60000); + +// ==================== 1. 404 页面测试 ==================== +test.describe('404 页面 - 未找到页面', () => { + + test('访问不存在的页面显示 404', async ({ page }) => { + await page.goto('/this-page-does-not-exist', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(1500); + + // URL 应保持为请求的路径或重定向到 404 处理 + expect(page.url()).toContain('this-page-does-not-exist'); + + // 检查 404 页面关键元素 + const heading = page.locator('h1').first(); + await expect(heading).toBeVisible(); + const headingText = await heading.textContent(); + expect(headingText).toMatch(/404|页面未找到/); + }); + + test('404 页面包含返回首页按钮', async ({ page }) => { + await page.goto('/non-existent-page', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(1500); + + const homeButton = page.locator('a[href="/"]').first(); + await expect(homeButton).toBeVisible(); + expect(await homeButton.textContent()).toContain('返回首页'); + }); + + test('404 页面推荐链接可点击', async ({ page }) => { + await page.goto('/missing-page', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(1500); + + const recommendedLinks = [ + { text: '产品', expectedPath: '/products' }, + { text: '解决方案', expectedPath: '/solutions' }, + { text: '关于我们', expectedPath: '/about' }, + { text: '联系我们', expectedPath: '/contact' }, + ]; + + for (const { text, expectedPath } of recommendedLinks) { + const link = page.locator(`a:has-text("${text}")`).first(); + if (await link.count() > 0 && await link.isVisible()) { + const href = await link.getAttribute('href'); + expect(href).toBe(expectedPath); + } + } + }); + + test('404 页面标题设置正确', async ({ page }) => { + await page.goto('/not-found-test', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(1500); + + const title = await page.title(); + expect(title).toContain('页面未找到'); + }); +}); + +// ==================== 2. 错误边界测试 ==================== +test.describe('错误边界 - 应用级错误处理', () => { + + test('错误页面包含重试按钮', async ({ page }) => { + // 通过直接访问错误页面组件路径模拟错误状态 + await page.goto('/error-test-trigger', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(1500); + + // 检查是否有返回首页链接(错误边界通用元素) + const homeLink = page.locator('a[href="/"]').first(); + if (await homeLink.count() > 0) { + await expect(homeLink).toBeVisible(); + } + }); + + test('错误页面包含返回首页链接', async ({ page }) => { + await page.goto('/error-boundary-test', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(1500); + + const homeLink = page.locator('a[href="/"]').first(); + if (await homeLink.count() > 0) { + const text = await homeLink.textContent(); + expect(text).toContain('返回首页'); + } + }); +}); + +// ==================== 3. 加载状态测试 ==================== +test.describe('加载状态 - 页面过渡', () => { + + test('页面加载时显示主要内容', async ({ page }) => { + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }); + + // 页面加载后 header 应可见 + await expect(page.locator('header')).toBeVisible({ timeout: 10000 }); + + // main 内容应存在 + const mainContent = page.locator('main, [role="main"]').first(); + await expect(mainContent).toBeVisible({ timeout: 10000 }); + }); + + test('页面导航不应完全空白', async ({ page }) => { + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(1000); + + // 点击到产品页面 + const productLink = page.locator('a[href="/products"]').first(); + if (await productLink.count() > 0 && await productLink.isVisible()) { + await productLink.click(); + await page.waitForTimeout(1000); + + // 导航过程中页面仍应包含某些元素 + const bodyText = await page.locator('body').textContent(); + expect(bodyText!.length).toBeGreaterThan(50); + } + }); + + test('刷新页面后内容仍然加载', async ({ page }) => { + await page.goto('/products', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(2000); + + // 刷新页面 + await page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(1500); + + // 页面应仍然加载成功 + const mainContent = page.locator('main, [role="main"]').first(); + await expect(mainContent).toBeVisible(); + }); +}); + +// ==================== 4. 无效路由与特殊字符测试 ==================== +test.describe('无效路由 - 边界 URL 处理', () => { + + test('带特殊字符的路由不崩溃', async ({ page }) => { + const specialPaths = [ + '/products/', + '/about?param=', + '/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 && } +