From df9ebe936c3f8b60b2df85ab9bf0327481884216 Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:59:02 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(shared):=20SlideshowPreview=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=208=20=E4=B8=AA=E6=B5=8B=E8=AF=95=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=B8=B2=E6=9F=93/=E5=AF=BC=E8=88=AA/aria-label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SlideshowPreview 组件此前无测试覆盖。新增测试验证: - 加载态/错误态渲染 - 幻灯片内容渲染 + 计数器 - close/prev/next 按钮 aria-label 匹配 key-echo 模式 - onClose 回调 - next 按钮前进到下一张 - prev 按钮在第一张时禁用 - 解析失败时显示错误 + 重试按钮 Co-authored-by: Cursor --- app/shared/src/ui/SlideshowPreview.test.tsx | 174 ++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 app/shared/src/ui/SlideshowPreview.test.tsx diff --git a/app/shared/src/ui/SlideshowPreview.test.tsx b/app/shared/src/ui/SlideshowPreview.test.tsx new file mode 100644 index 000000000..d4f2649f0 --- /dev/null +++ b/app/shared/src/ui/SlideshowPreview.test.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import React from 'react'; + +/* ═══════════════════════════════════════════════════════════════════════ + Mock jszip + ───────── + SlideshowPreview lazily does `const mod = await import('jszip')` and then + reads `mod.default.loadAsync(...)`. We replace the jszip module with a + default whose `loadAsync` resolves to a fake zip instance. + + The fake `forEach` enumerates two slide entries; `file()` returns an + object whose `async('string')` yields the slide XML for those entries and + `null` for any other path (so the rels/image-extraction branch is + skipped — the component only enters it when `zip.file(relsPath)` is + truthy). + + Slide text runs are intentionally longer than 20 characters. The + component truncates the first run to 20 chars for the thumbnail label, + so the truncated label ("First Slide Heading Te") differs from the full + text rendered in the slide `

` ("First Slide Heading Text"). This + keeps `findByText` unambiguous — without it, the slide `

` and the + thumbnail button would both contain the identical string and + `findByText` would throw on a multiple-match. + ═══════════════════════════════════════════════════════════════════════ */ + +const mockSlideContents: Record = { + 'ppt/slides/slide1.xml': + 'First Slide Heading TextFirst body content', + 'ppt/slides/slide2.xml': 'Second Slide Heading Text', +}; + +interface MockZipFile { + async(type: string): Promise; +} + +interface MockZipInstance { + forEach( + callback: ( + relativePath: string, + file: { dir: boolean; name: string }, + ) => void, + ): void; + file(path: string): MockZipFile | null; +} + +function buildMockZipInstance(): MockZipInstance { + return { + forEach(callback) { + const entries = [ + { + relativePath: 'ppt/slides/slide1.xml', + file: { dir: false, name: 'slide1.xml' }, + }, + { + relativePath: 'ppt/slides/slide2.xml', + file: { dir: false, name: 'slide2.xml' }, + }, + ]; + for (const entry of entries) { + callback(entry.relativePath, entry.file); + } + }, + file(path: string) { + const content = mockSlideContents[path]; + if (content === undefined) return null; + return { + async(type: string): Promise { + if (type === 'string') return content; + if (type === 'blob') return new Blob([content], { type: 'text/xml' }); + throw new Error(`unexpected async type: ${type}`); + }, + }; + }, + }; +} + +const loadAsyncMock = vi.fn(async () => buildMockZipInstance()); + +vi.mock('jszip', () => ({ + default: { + loadAsync: loadAsyncMock, + }, +})); + +import { SlideshowPreview } from './SlideshowPreview'; + +/** Minimal Blob stub — SlideshowPreview only calls `.arrayBuffer()` on it. */ +function makeBlob(): Blob { + const blob = { arrayBuffer: async () => new ArrayBuffer(0) } as unknown as Blob; + return blob; +} + +function renderSlideshow(options: { onClose?: () => void } = {}) { + return render( + , + ); +} + +describe('SlideshowPreview', () => { + it('renders loading state initially', () => { + // Keep loadAsync pending so the component stays in the loading branch. + loadAsyncMock.mockImplementationOnce(() => new Promise(() => {})); + const { getByText } = renderSlideshow(); + expect(getByText('正在解析演示文稿...')).toBeInTheDocument(); + }); + + it('renders slides after loading', async () => { + const { findByText, getByText } = renderSlideshow(); + // First slide's heading

renders once the parse completes. + expect(await findByText('First Slide Heading Text')).toBeInTheDocument(); + // Second text run on slide 1. + expect(getByText('First body content')).toBeInTheDocument(); + // Counter shows current position / total. + expect(getByText('1 / 2')).toBeInTheDocument(); + }); + + it('shows close button with key-echo aria-label', async () => { + const onClose = vi.fn(); + const { findByRole } = renderSlideshow({ onClose }); + // The shared test i18n instance runs in key-echo mode, so + // t('aria.closePreview') returns 'aria.closePreview'. + const closeButton = await findByRole('button', { name: 'aria.closePreview' }); + expect(closeButton).toBeInTheDocument(); + }); + + it('shows prev/next buttons with key-echo aria-labels', async () => { + const { findByRole } = renderSlideshow(); + const prevButton = await findByRole('button', { name: 'aria.previousImage' }); + const nextButton = await findByRole('button', { name: 'aria.nextImage' }); + expect(prevButton).toBeInTheDocument(); + expect(nextButton).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', async () => { + const onClose = vi.fn(); + const { findByRole } = renderSlideshow({ onClose }); + const closeButton = await findByRole('button', { name: 'aria.closePreview' }); + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('advances to slide 2 when next button is clicked', async () => { + const { findByRole, findByText, getByText } = renderSlideshow(); + // Wait for slide 1 to be rendered before interacting. + await findByText('First Slide Heading Text'); + const nextButton = await findByRole('button', { name: 'aria.nextImage' }); + fireEvent.click(nextButton); + // Slide 2 heading now renders in the slide canvas. + expect(await findByText('Second Slide Heading Text')).toBeInTheDocument(); + expect(getByText('2 / 2')).toBeInTheDocument(); + }); + + it('disables prev button on the first slide', async () => { + const { findByRole } = renderSlideshow(); + const prevButton = await findByRole('button', { name: 'aria.previousImage' }); + expect(prevButton).toBeDisabled(); + }); + + it('shows error state when parsing fails', async () => { + loadAsyncMock.mockRejectedValueOnce(new Error('parse failed')); + const { findByText, getByRole } = renderSlideshow(); + // The component surfaces err.message into the error block. + expect(await findByText('parse failed')).toBeInTheDocument(); + // Retry button is rendered with its visible text label. + expect(getByRole('button', { name: '重试' })).toBeInTheDocument(); + }); +}); From e780dd56aefe430548731842ff7fdfaf8ef15ab8 Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:03:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(shared):=20CodeBlock/PageErrorBoundary?= =?UTF-8?q?/DiffReviewPanelParts=20=E8=A1=A5=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 个 shared 组件此前无测试覆盖,新增 15 个测试: - CodeBlock (6 tests): inline vs block 渲染、language label、copy 按钮 aria-label key-echo、clipboard 调用、collapse/expand toggle 长代码 - PageErrorBoundary (3 tests): 正常渲染、catch error 显示 alert+title、 Retry 按钮调用 onReset - DiffReviewPanelParts (6 tests): file tabs 渲染/aria-selected/click、 toolbar stats 渲染、modified count 零值隐藏、accept/reject 回调 AGENTS.md §5 三件套覆盖缺口从 4 降至 0。 Co-authored-by: Cursor --- app/shared/src/ui/CodeBlock.test.tsx | 91 ++++++++++++++ .../src/ui/DiffReviewPanelParts.test.tsx | 111 ++++++++++++++++++ app/shared/src/ui/PageErrorBoundary.test.tsx | 54 +++++++++ 3 files changed, 256 insertions(+) create mode 100644 app/shared/src/ui/CodeBlock.test.tsx create mode 100644 app/shared/src/ui/DiffReviewPanelParts.test.tsx create mode 100644 app/shared/src/ui/PageErrorBoundary.test.tsx diff --git a/app/shared/src/ui/CodeBlock.test.tsx b/app/shared/src/ui/CodeBlock.test.tsx new file mode 100644 index 000000000..06531c140 --- /dev/null +++ b/app/shared/src/ui/CodeBlock.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import React from 'react'; + +// Mock react-syntax-highlighter so we don't load the heavy Prism bundle. +// CodeBlock lazy-imports PrismLight + oneDark; we stub them to render raw code. +vi.mock('react-syntax-highlighter', () => ({ + PrismLight: ({ code }: { code: string }) => + React.createElement('pre', null, React.createElement('code', null, code)), +})); +vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ + oneDark: {}, +})); +vi.mock('./prismRegistry', () => ({})); + +// Mock clipboard so the copy button can be exercised. +const writeText = vi.fn().mockResolvedValue(undefined); +Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, +}); + +import { CodeBlock } from './CodeBlock'; + +describe('CodeBlock', () => { + it('renders inline code (no language, no trailing newline) as ', () => { + render(inline snippet); + expect(screen.getByText('inline snippet')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders block code with language label and copy button', () => { + render( + + {'const x = 1;\n'} + , + ); + expect(screen.getByText('typescript')).toBeInTheDocument(); + expect(screen.getByText('const x = 1;')).toBeInTheDocument(); + const copyBtn = screen.getByRole('button', { name: 'code.copy' }); + expect(copyBtn).toBeInTheDocument(); + }); + + it('shows code.copied aria-label after clicking copy', async () => { + render( + + {'print("hello")\n'} + , + ); + const copyBtn = screen.getByRole('button', { name: 'code.copy' }); + fireEvent.click(copyBtn); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'code.copied' })).toBeInTheDocument(); + }); + expect(writeText).toHaveBeenCalledWith('print("hello")'); + }); + + it('shows collapse/expand toggle for code longer than 20 lines', () => { + const longCode = Array.from({ length: 25 }, (_, i) => `line ${i}`).join('\n') + '\n'; + render( + + {longCode} + , + ); + const toggle = screen.getByRole('button', { name: 'code.expand' }); + expect(toggle).toBeInTheDocument(); + }); + + it('does not show collapse toggle for short code', () => { + render( + + {'short\n'} + , + ); + expect(screen.queryByRole('button', { name: 'code.expand' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'code.collapse' })).not.toBeInTheDocument(); + }); + + it('toggles between expand and collapse labels', () => { + const longCode = Array.from({ length: 25 }, (_, i) => `line ${i}`).join('\n') + '\n'; + render( + + {longCode} + , + ); + const expandBtn = screen.getByRole('button', { name: 'code.expand' }); + fireEvent.click(expandBtn); + expect(screen.getByRole('button', { name: 'code.collapse' })).toBeInTheDocument(); + }); +}); diff --git a/app/shared/src/ui/DiffReviewPanelParts.test.tsx b/app/shared/src/ui/DiffReviewPanelParts.test.tsx new file mode 100644 index 000000000..c2f0610d9 --- /dev/null +++ b/app/shared/src/ui/DiffReviewPanelParts.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import React from 'react'; + +// Mock syntaxHighlight so we don't load the heavy Prism/refractor bundle. +vi.mock('./syntaxHighlight', () => ({ + highlightLine: (content: string) => content, + highlightLineWithWordDiff: (content: string) => content, +})); + +import { + DiffReviewFileTabs, + DiffReviewToolbar, +} from './DiffReviewPanelParts'; +import type { DiffReviewFile } from './DiffReviewPanelTypes'; + +const mockFiles: DiffReviewFile[] = [ + { filePath: 'src/index.ts', status: 'modified', hunks: [] }, + { filePath: 'src/utils.ts', status: 'added', hunks: [] }, + { filePath: 'src/old.ts', status: 'deleted', hunks: [] }, +]; + +describe('DiffReviewFileTabs', () => { + it('renders a tab for each file', () => { + render( + , + ); + expect(screen.getByText('src/index.ts')).toBeInTheDocument(); + expect(screen.getByText('src/utils.ts')).toBeInTheDocument(); + expect(screen.getByText('src/old.ts')).toBeInTheDocument(); + }); + + it('marks the active tab with aria-selected', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('aria-selected', 'false'); + expect(tabs[1]).toHaveAttribute('aria-selected', 'true'); + expect(tabs[2]).toHaveAttribute('aria-selected', 'false'); + }); + + it('calls onSelectFile with the correct index when a tab is clicked', () => { + const onSelectFile = vi.fn(); + render( + , + ); + const tabs = screen.getAllByRole('tab'); + fireEvent.click(tabs[2]); + expect(onSelectFile).toHaveBeenCalledWith(2); + }); +}); + +describe('DiffReviewToolbar', () => { + it('renders file path and diff stats', () => { + render( + , + ); + expect(screen.getByText('src/index.ts')).toBeInTheDocument(); + expect(screen.getByText('+5')).toBeInTheDocument(); + expect(screen.getByText('-2')).toBeInTheDocument(); + expect(screen.getByText('~3')).toBeInTheDocument(); + }); + + it('does not show modified count when zero', () => { + render( + , + ); + expect(screen.queryByText('~0')).not.toBeInTheDocument(); + }); + + it('calls onAcceptAll and onRejectAll when buttons are clicked', () => { + const onAcceptAll = vi.fn(); + const onRejectAll = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Accept all' })); + expect(onAcceptAll).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'Reject all' })); + expect(onRejectAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/shared/src/ui/PageErrorBoundary.test.tsx b/app/shared/src/ui/PageErrorBoundary.test.tsx new file mode 100644 index 000000000..c864026e7 --- /dev/null +++ b/app/shared/src/ui/PageErrorBoundary.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { PageErrorBoundary } from './PageErrorBoundary'; + +function Thrower({ error }: { error: Error }): never { + throw error; +} + +let consoleErrorSpy: ReturnType; + +beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + sessionStorage.clear(); +}); + +afterEach(() => { + consoleErrorSpy.mockRestore(); +}); + +describe('PageErrorBoundary', () => { + it('renders children when no error', () => { + render( + +

Page content

+ , + ); + expect(screen.getByText('Page content')).toBeInTheDocument(); + }); + + it('catches render errors and shows error UI', () => { + render( + + + , + ); + expect(screen.queryByText('Page content')).not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('calls onReset when user retries', () => { + const onReset = vi.fn(); + render( + + + , + ); + const retryButton = screen.getByText('Retry'); + fireEvent.click(retryButton); + expect(onReset).toHaveBeenCalledTimes(1); + }); +});