This commit is contained in:
2026-01-25 20:12:33 +08:00
parent 3c3868e2a7
commit fd7cb4485c
364 changed files with 66196 additions and 0 deletions

View File

@@ -0,0 +1,555 @@
/**
* ImagePreview Component Unit Tests
* Tests for full-screen image preview with navigation
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ImagePreview } from './ImagePreview';
import type { TransactionImage } from '../../../types';
describe('ImagePreview', () => {
const mockImages: TransactionImage[] = [
{
id: 1,
transactionId: 100,
filePath: '/uploads/image1.jpg',
fileName: 'receipt1.jpg',
fileSize: 102400, // 100 KB
mimeType: 'image/jpeg',
createdAt: '2024-01-01T10:00:00Z',
},
{
id: 2,
transactionId: 100,
filePath: '/uploads/image2.jpg',
fileName: 'receipt2.jpg',
fileSize: 204800, // 200 KB
mimeType: 'image/jpeg',
createdAt: '2024-01-01T10:01:00Z',
},
{
id: 3,
transactionId: 100,
filePath: '/uploads/image3.jpg',
fileName: 'receipt3.jpg',
fileSize: 153600, // 150 KB
mimeType: 'image/jpeg',
createdAt: '2024-01-01T10:02:00Z',
},
];
const mockOnClose = vi.fn();
beforeEach(() => {
mockOnClose.mockClear();
});
afterEach(() => {
// Restore body overflow
document.body.style.overflow = '';
});
describe('Rendering', () => {
it('should not render when open is false', () => {
const { container } = render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={false}
onClose={mockOnClose}
/>
);
expect(container.firstChild).toBeNull();
});
it('should render when open is true', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByLabelText('图片预览')).toBeInTheDocument();
});
it('should not render when images array is empty', () => {
const { container } = render(
<ImagePreview
images={[]}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(container.firstChild).toBeNull();
});
it('should display the correct image at initialIndex', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={1}
open={true}
onClose={mockOnClose}
/>
);
const image = screen.getByAltText('receipt2.jpg');
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute('src', expect.stringContaining('/images/2'));
});
it('should display image counter', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
it('should display image filename and filesize', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByText('receipt1.jpg')).toBeInTheDocument();
expect(screen.getByText('100.0 KB')).toBeInTheDocument();
});
it('should display navigation buttons when multiple images', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByLabelText('上一张')).toBeInTheDocument();
expect(screen.getByLabelText('下一张')).toBeInTheDocument();
});
it('should not display navigation buttons when single image', () => {
render(
<ImagePreview
images={[mockImages[0]]}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.queryByLabelText('上一张')).not.toBeInTheDocument();
expect(screen.queryByLabelText('下一张')).not.toBeInTheDocument();
});
});
describe('Close Functionality', () => {
it('should call onClose when close button is clicked', async () => {
const user = userEvent.setup();
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
const closeButton = screen.getByLabelText('关闭预览');
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should call onClose when overlay is clicked', async () => {
const user = userEvent.setup();
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
const overlay = screen.getByRole('dialog');
await user.click(overlay);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should not call onClose when image container is clicked', async () => {
const user = userEvent.setup();
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
const image = screen.getByAltText('receipt1.jpg');
await user.click(image);
expect(mockOnClose).not.toHaveBeenCalled();
});
it('should call onClose when Escape key is pressed', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('Navigation', () => {
it('should navigate to next image when next button is clicked', async () => {
const user = userEvent.setup();
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByText('1 / 3')).toBeInTheDocument();
expect(screen.getByAltText('receipt1.jpg')).toBeInTheDocument();
const nextButton = screen.getByLabelText('下一张');
await user.click(nextButton);
await waitFor(() => {
expect(screen.getByText('2 / 3')).toBeInTheDocument();
expect(screen.getByAltText('receipt2.jpg')).toBeInTheDocument();
});
});
it('should navigate to previous image when previous button is clicked', async () => {
const user = userEvent.setup();
render(
<ImagePreview
images={mockImages}
initialIndex={1}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByText('2 / 3')).toBeInTheDocument();
expect(screen.getByAltText('receipt2.jpg')).toBeInTheDocument();
const prevButton = screen.getByLabelText('上一张');
await user.click(prevButton);
await waitFor(() => {
expect(screen.getByText('1 / 3')).toBeInTheDocument();
expect(screen.getByAltText('receipt1.jpg')).toBeInTheDocument();
});
});
it('should wrap to last image when clicking previous on first image', async () => {
const user = userEvent.setup();
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByText('1 / 3')).toBeInTheDocument();
const prevButton = screen.getByLabelText('上一张');
await user.click(prevButton);
await waitFor(() => {
expect(screen.getByText('3 / 3')).toBeInTheDocument();
expect(screen.getByAltText('receipt3.jpg')).toBeInTheDocument();
});
});
it('should wrap to first image when clicking next on last image', async () => {
const user = userEvent.setup();
render(
<ImagePreview
images={mockImages}
initialIndex={2}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByText('3 / 3')).toBeInTheDocument();
const nextButton = screen.getByLabelText('下一张');
await user.click(nextButton);
await waitFor(() => {
expect(screen.getByText('1 / 3')).toBeInTheDocument();
expect(screen.getByAltText('receipt1.jpg')).toBeInTheDocument();
});
});
it('should navigate to next image when ArrowRight key is pressed', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByText('1 / 3')).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowRight' });
waitFor(() => {
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
});
it('should navigate to previous image when ArrowLeft key is pressed', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={1}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByText('2 / 3')).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowLeft' });
waitFor(() => {
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
});
});
describe('Body Scroll Lock', () => {
it('should lock body scroll when modal is open', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(document.body.style.overflow).toBe('hidden');
});
it('should restore body scroll when modal is closed', () => {
const { rerender } = render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(document.body.style.overflow).toBe('hidden');
rerender(
<ImagePreview
images={mockImages}
initialIndex={0}
open={false}
onClose={mockOnClose}
/>
);
expect(document.body.style.overflow).toBe('');
});
});
describe('Index Reset', () => {
it('should reset to initialIndex when it changes', () => {
const { rerender } = render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByText('1 / 3')).toBeInTheDocument();
rerender(
<ImagePreview
images={mockImages}
initialIndex={2}
open={true}
onClose={mockOnClose}
/>
);
waitFor(() => {
expect(screen.getByText('3 / 3')).toBeInTheDocument();
});
});
});
describe('Touch/Swipe Navigation', () => {
it('should navigate to next image on left swipe', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
const container = screen.getByAltText('receipt1.jpg').parentElement!;
// Simulate left swipe (swipe from right to left)
fireEvent.touchStart(container, {
targetTouches: [{ clientX: 200 }],
});
fireEvent.touchMove(container, {
targetTouches: [{ clientX: 100 }],
});
fireEvent.touchEnd(container);
waitFor(() => {
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
});
it('should navigate to previous image on right swipe', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={1}
open={true}
onClose={mockOnClose}
/>
);
const container = screen.getByAltText('receipt2.jpg').parentElement!;
// Simulate right swipe (swipe from left to right)
fireEvent.touchStart(container, {
targetTouches: [{ clientX: 100 }],
});
fireEvent.touchMove(container, {
targetTouches: [{ clientX: 200 }],
});
fireEvent.touchEnd(container);
waitFor(() => {
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
});
it('should not navigate on small swipe distance', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
const container = screen.getByAltText('receipt1.jpg').parentElement!;
// Simulate small swipe (less than minimum distance)
fireEvent.touchStart(container, {
targetTouches: [{ clientX: 100 }],
});
fireEvent.touchMove(container, {
targetTouches: [{ clientX: 120 }],
});
fireEvent.touchEnd(container);
// Should still be on first image
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have proper ARIA attributes', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-label', '图片预览');
});
it('should have proper button labels', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
expect(screen.getByLabelText('关闭预览')).toBeInTheDocument();
expect(screen.getByLabelText('上一张')).toBeInTheDocument();
expect(screen.getByLabelText('下一张')).toBeInTheDocument();
});
it('should have non-draggable image', () => {
render(
<ImagePreview
images={mockImages}
initialIndex={0}
open={true}
onClose={mockOnClose}
/>
);
const image = screen.getByAltText('receipt1.jpg');
expect(image).toHaveAttribute('draggable', 'false');
});
});
});