998 lines
32 KiB
TypeScript
998 lines
32 KiB
TypeScript
/**
|
|
* Component tests for upload components
|
|
* Tests FilePicker, DropZone, ProgressBar, and ErrorDisplay Svelte components
|
|
*/
|
|
|
|
import { render, fireEvent, screen, waitFor } from '@testing-library/svelte';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import FilePicker from '$lib/components/upload/FilePicker.svelte';
|
|
import DropZone from '$lib/components/upload/DropZone.svelte';
|
|
import ProgressBar from '$lib/components/upload/ProgressBar.svelte';
|
|
import ErrorDisplay from '$lib/components/upload/ErrorDisplay.svelte';
|
|
import type { ImageUploadProgress } from '$lib/types/images';
|
|
|
|
// Mock the image store functions
|
|
vi.mock('$lib/stores/images', () => ({
|
|
uploadSingleImage: vi.fn(),
|
|
uploadZipFile: vi.fn(),
|
|
uploadProgress: {
|
|
update: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
describe('FilePicker', () => {
|
|
let uploadSingleImage: ReturnType<typeof vi.fn>;
|
|
let uploadZipFile: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(async () => {
|
|
const imageStore = await import('$lib/stores/images');
|
|
uploadSingleImage = imageStore.uploadSingleImage;
|
|
uploadZipFile = imageStore.uploadZipFile;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('renders the file picker button', () => {
|
|
render(FilePicker);
|
|
|
|
const button = screen.getByRole('button', { name: /choose files/i });
|
|
expect(button).toBeInTheDocument();
|
|
expect(button).not.toBeDisabled();
|
|
});
|
|
|
|
it('renders with custom accept attribute', () => {
|
|
render(FilePicker, { props: { accept: 'image/png,.jpg' } });
|
|
|
|
const button = screen.getByRole('button');
|
|
expect(button).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders with multiple attribute by default', () => {
|
|
const { container } = render(FilePicker);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]');
|
|
expect(fileInput).toHaveAttribute('multiple');
|
|
});
|
|
|
|
it('can disable multiple file selection', () => {
|
|
const { container } = render(FilePicker, { props: { multiple: false } });
|
|
|
|
const fileInput = container.querySelector('input[type="file"]');
|
|
expect(fileInput).not.toHaveAttribute('multiple');
|
|
});
|
|
|
|
it('hides the file input element', () => {
|
|
const { container } = render(FilePicker);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLElement;
|
|
expect(fileInput).toHaveStyle({ display: 'none' });
|
|
});
|
|
});
|
|
|
|
describe('File Selection', () => {
|
|
it('opens file picker when button is clicked', async () => {
|
|
const { container } = render(FilePicker);
|
|
|
|
const button = screen.getByRole('button', { name: /choose files/i });
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const clickSpy = vi.fn();
|
|
fileInput.click = clickSpy;
|
|
|
|
await fireEvent.click(button);
|
|
|
|
expect(clickSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('handles single image file upload', async () => {
|
|
uploadSingleImage.mockResolvedValue({ success: true });
|
|
|
|
const { container, component } = render(FilePicker);
|
|
|
|
const uploadCompleteHandler = vi.fn();
|
|
component.$on('upload-complete', uploadCompleteHandler);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const file = new File(['image content'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
await fireEvent.change(fileInput, { target: { files: [file] } });
|
|
|
|
await waitFor(() => {
|
|
expect(uploadSingleImage).toHaveBeenCalledWith(file);
|
|
});
|
|
|
|
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
|
|
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 1 });
|
|
});
|
|
|
|
it('handles multiple image file uploads', async () => {
|
|
uploadSingleImage.mockResolvedValue({ success: true });
|
|
|
|
const { container, component } = render(FilePicker);
|
|
|
|
const uploadCompleteHandler = vi.fn();
|
|
component.$on('upload-complete', uploadCompleteHandler);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const files = [
|
|
new File(['image1'], 'test1.jpg', { type: 'image/jpeg' }),
|
|
new File(['image2'], 'test2.png', { type: 'image/png' }),
|
|
new File(['image3'], 'test3.gif', { type: 'image/gif' }),
|
|
];
|
|
|
|
await fireEvent.change(fileInput, { target: { files } });
|
|
|
|
await waitFor(() => {
|
|
expect(uploadSingleImage).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
|
|
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 3 });
|
|
});
|
|
|
|
it('handles ZIP file upload', async () => {
|
|
uploadZipFile.mockResolvedValue({ success: true });
|
|
|
|
const { container, component } = render(FilePicker);
|
|
|
|
const uploadCompleteHandler = vi.fn();
|
|
component.$on('upload-complete', uploadCompleteHandler);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const file = new File(['zip content'], 'images.zip', { type: 'application/zip' });
|
|
|
|
await fireEvent.change(fileInput, { target: { files: [file] } });
|
|
|
|
await waitFor(() => {
|
|
expect(uploadZipFile).toHaveBeenCalledWith(file);
|
|
});
|
|
|
|
expect(uploadSingleImage).not.toHaveBeenCalled();
|
|
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('handles mixed image and ZIP file uploads', async () => {
|
|
uploadSingleImage.mockResolvedValue({ success: true });
|
|
uploadZipFile.mockResolvedValue({ success: true });
|
|
|
|
const { container, component } = render(FilePicker);
|
|
|
|
const uploadCompleteHandler = vi.fn();
|
|
component.$on('upload-complete', uploadCompleteHandler);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const files = [
|
|
new File(['image'], 'test.jpg', { type: 'image/jpeg' }),
|
|
new File(['zip'], 'archive.zip', { type: 'application/zip' }),
|
|
new File(['image'], 'test.png', { type: 'image/png' }),
|
|
];
|
|
|
|
await fireEvent.change(fileInput, { target: { files } });
|
|
|
|
await waitFor(() => {
|
|
expect(uploadSingleImage).toHaveBeenCalledTimes(2);
|
|
expect(uploadZipFile).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
|
|
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 3 });
|
|
});
|
|
|
|
it('resets file input after upload', async () => {
|
|
uploadSingleImage.mockResolvedValue({ success: true });
|
|
|
|
const { container } = render(FilePicker);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
await fireEvent.change(fileInput, { target: { files: [file] } });
|
|
|
|
await waitFor(() => {
|
|
expect(uploadSingleImage).toHaveBeenCalled();
|
|
});
|
|
|
|
expect(fileInput.value).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('Loading State', () => {
|
|
it('shows loading state during upload', async () => {
|
|
uploadSingleImage.mockImplementation(
|
|
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
|
|
);
|
|
|
|
const { container } = render(FilePicker);
|
|
|
|
const button = screen.getByRole('button');
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
await fireEvent.change(fileInput, { target: { files: [file] } });
|
|
|
|
// During upload
|
|
expect(button).toBeDisabled();
|
|
expect(screen.getByText(/uploading/i)).toBeInTheDocument();
|
|
|
|
// Wait for upload to complete
|
|
await waitFor(() => {
|
|
expect(button).not.toBeDisabled();
|
|
});
|
|
|
|
expect(screen.queryByText(/uploading/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('disables button during upload', async () => {
|
|
uploadSingleImage.mockImplementation(
|
|
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
|
|
);
|
|
|
|
const { container } = render(FilePicker);
|
|
|
|
const button = screen.getByRole('button');
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
expect(button).not.toBeDisabled();
|
|
|
|
await fireEvent.change(fileInput, { target: { files: [file] } });
|
|
|
|
expect(button).toBeDisabled();
|
|
|
|
await waitFor(() => {
|
|
expect(button).not.toBeDisabled();
|
|
});
|
|
});
|
|
|
|
it('shows spinner during upload', async () => {
|
|
uploadSingleImage.mockImplementation(
|
|
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
|
|
);
|
|
|
|
const { container } = render(FilePicker);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
await fireEvent.change(fileInput, { target: { files: [file] } });
|
|
|
|
const spinner = container.querySelector('.spinner');
|
|
expect(spinner).toBeInTheDocument();
|
|
|
|
await waitFor(() => {
|
|
expect(container.querySelector('.spinner')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('dispatches upload-error event on upload failure', async () => {
|
|
uploadSingleImage.mockRejectedValue(new Error('Upload failed'));
|
|
|
|
const { container, component } = render(FilePicker);
|
|
|
|
const uploadErrorHandler = vi.fn();
|
|
component.$on('upload-error', uploadErrorHandler);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
await fireEvent.change(fileInput, { target: { files: [file] } });
|
|
|
|
await waitFor(() => {
|
|
expect(uploadErrorHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({ error: 'Upload failed' });
|
|
});
|
|
|
|
it('re-enables button after error', async () => {
|
|
uploadSingleImage.mockRejectedValue(new Error('Upload failed'));
|
|
|
|
const { container } = render(FilePicker);
|
|
|
|
const button = screen.getByRole('button');
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
await fireEvent.change(fileInput, { target: { files: [file] } });
|
|
|
|
await waitFor(() => {
|
|
expect(button).not.toBeDisabled();
|
|
});
|
|
});
|
|
|
|
it('handles no files selected gracefully', async () => {
|
|
const { container } = render(FilePicker);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
await fireEvent.change(fileInput, { target: { files: null } });
|
|
|
|
expect(uploadSingleImage).not.toHaveBeenCalled();
|
|
expect(uploadZipFile).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('DropZone', () => {
|
|
let uploadSingleImage: ReturnType<typeof vi.fn>;
|
|
let uploadZipFile: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(async () => {
|
|
const imageStore = await import('$lib/stores/images');
|
|
uploadSingleImage = imageStore.uploadSingleImage;
|
|
uploadZipFile = imageStore.uploadZipFile;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('renders the drop zone', () => {
|
|
render(DropZone);
|
|
|
|
expect(screen.getByText(/drag and drop images here/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/or use the file picker above/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows default state initially', () => {
|
|
const { container } = render(DropZone);
|
|
|
|
const dropZone = container.querySelector('.drop-zone');
|
|
expect(dropZone).not.toHaveClass('dragging');
|
|
expect(dropZone).not.toHaveClass('uploading');
|
|
});
|
|
});
|
|
|
|
describe('Drag and Drop', () => {
|
|
it('shows dragging state on drag enter', async () => {
|
|
const { container } = render(DropZone);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
await fireEvent.dragEnter(dropZone);
|
|
|
|
expect(dropZone).toHaveClass('dragging');
|
|
expect(screen.getByText(/drop files here/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('removes dragging state on drag leave', async () => {
|
|
const { container } = render(DropZone);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
await fireEvent.dragEnter(dropZone);
|
|
expect(dropZone).toHaveClass('dragging');
|
|
|
|
await fireEvent.dragLeave(dropZone);
|
|
expect(dropZone).not.toHaveClass('dragging');
|
|
});
|
|
|
|
it('handles drag over event', async () => {
|
|
const { container } = render(DropZone);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true });
|
|
const preventDefaultSpy = vi.spyOn(dragOverEvent, 'preventDefault');
|
|
|
|
dropZone.dispatchEvent(dragOverEvent);
|
|
|
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles single image file drop', async () => {
|
|
uploadSingleImage.mockResolvedValue({ success: true });
|
|
|
|
const { container, component } = render(DropZone);
|
|
|
|
const uploadCompleteHandler = vi.fn();
|
|
component.$on('upload-complete', uploadCompleteHandler);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
const dropEvent = new DragEvent('drop', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
dataTransfer: new DataTransfer(),
|
|
});
|
|
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: {
|
|
files: [file],
|
|
},
|
|
});
|
|
|
|
await fireEvent(dropZone, dropEvent);
|
|
|
|
await waitFor(() => {
|
|
expect(uploadSingleImage).toHaveBeenCalledWith(file);
|
|
});
|
|
|
|
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
|
|
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 1 });
|
|
});
|
|
|
|
it('handles multiple image files drop', async () => {
|
|
uploadSingleImage.mockResolvedValue({ success: true });
|
|
|
|
const { container, component } = render(DropZone);
|
|
|
|
const uploadCompleteHandler = vi.fn();
|
|
component.$on('upload-complete', uploadCompleteHandler);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
const files = [
|
|
new File(['image1'], 'test1.jpg', { type: 'image/jpeg' }),
|
|
new File(['image2'], 'test2.png', { type: 'image/png' }),
|
|
];
|
|
|
|
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: { files },
|
|
});
|
|
|
|
await fireEvent(dropZone, dropEvent);
|
|
|
|
await waitFor(() => {
|
|
expect(uploadSingleImage).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('handles ZIP file drop', async () => {
|
|
uploadZipFile.mockResolvedValue({ success: true });
|
|
|
|
const { container, component } = render(DropZone);
|
|
|
|
const uploadCompleteHandler = vi.fn();
|
|
component.$on('upload-complete', uploadCompleteHandler);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
const file = new File(['zip'], 'images.zip', { type: 'application/zip' });
|
|
|
|
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: { files: [file] },
|
|
});
|
|
|
|
await fireEvent(dropZone, dropEvent);
|
|
|
|
await waitFor(() => {
|
|
expect(uploadZipFile).toHaveBeenCalledWith(file);
|
|
});
|
|
|
|
expect(uploadSingleImage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('filters out invalid file types', async () => {
|
|
const { container, component } = render(DropZone, { props: { accept: 'image/*,.zip' } });
|
|
|
|
const uploadErrorHandler = vi.fn();
|
|
component.$on('upload-error', uploadErrorHandler);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
const files = [new File(['text'], 'document.txt', { type: 'text/plain' })];
|
|
|
|
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: { files },
|
|
});
|
|
|
|
await fireEvent(dropZone, dropEvent);
|
|
|
|
await waitFor(() => {
|
|
expect(uploadErrorHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({
|
|
error: 'No valid image files found',
|
|
});
|
|
|
|
expect(uploadSingleImage).not.toHaveBeenCalled();
|
|
expect(uploadZipFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes dragging state after drop', async () => {
|
|
uploadSingleImage.mockResolvedValue({ success: true });
|
|
|
|
const { container } = render(DropZone);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
await fireEvent.dragEnter(dropZone);
|
|
expect(dropZone).toHaveClass('dragging');
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: { files: [file] },
|
|
});
|
|
|
|
await fireEvent(dropZone, dropEvent);
|
|
|
|
expect(dropZone).not.toHaveClass('dragging');
|
|
});
|
|
});
|
|
|
|
describe('Loading State', () => {
|
|
it('shows uploading state during upload', async () => {
|
|
uploadSingleImage.mockImplementation(
|
|
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
|
|
);
|
|
|
|
const { container } = render(DropZone);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: { files: [file] },
|
|
});
|
|
|
|
await fireEvent(dropZone, dropEvent);
|
|
|
|
expect(dropZone).toHaveClass('uploading');
|
|
expect(screen.getByText(/uploading/i)).toBeInTheDocument();
|
|
|
|
await waitFor(() => {
|
|
expect(dropZone).not.toHaveClass('uploading');
|
|
});
|
|
});
|
|
|
|
it('shows spinner during upload', async () => {
|
|
uploadSingleImage.mockImplementation(
|
|
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
|
|
);
|
|
|
|
const { container } = render(DropZone);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: { files: [file] },
|
|
});
|
|
|
|
await fireEvent(dropZone, dropEvent);
|
|
|
|
const spinner = container.querySelector('.spinner-large');
|
|
expect(spinner).toBeInTheDocument();
|
|
|
|
await waitFor(() => {
|
|
expect(container.querySelector('.spinner-large')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('dispatches upload-error event on upload failure', async () => {
|
|
uploadSingleImage.mockRejectedValue(new Error('Network error'));
|
|
|
|
const { container, component } = render(DropZone);
|
|
|
|
const uploadErrorHandler = vi.fn();
|
|
component.$on('upload-error', uploadErrorHandler);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: { files: [file] },
|
|
});
|
|
|
|
await fireEvent(dropZone, dropEvent);
|
|
|
|
await waitFor(() => {
|
|
expect(uploadErrorHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({ error: 'Network error' });
|
|
});
|
|
|
|
it('returns to normal state after error', async () => {
|
|
uploadSingleImage.mockRejectedValue(new Error('Upload failed'));
|
|
|
|
const { container } = render(DropZone);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
|
|
|
|
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: { files: [file] },
|
|
});
|
|
|
|
await fireEvent(dropZone, dropEvent);
|
|
|
|
await waitFor(() => {
|
|
expect(dropZone).not.toHaveClass('uploading');
|
|
});
|
|
});
|
|
|
|
it('handles drop event with no files', async () => {
|
|
const { container } = render(DropZone);
|
|
|
|
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
|
|
|
|
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: { files: null },
|
|
});
|
|
|
|
await fireEvent(dropZone, dropEvent);
|
|
|
|
expect(uploadSingleImage).not.toHaveBeenCalled();
|
|
expect(uploadZipFile).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ProgressBar', () => {
|
|
describe('Rendering', () => {
|
|
it('renders progress item with filename', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test-image.jpg',
|
|
status: 'uploading',
|
|
progress: 50,
|
|
};
|
|
|
|
render(ProgressBar, { props: { item } });
|
|
|
|
expect(screen.getByText('test-image.jpg')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows progress bar for uploading status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'uploading',
|
|
progress: 75,
|
|
};
|
|
|
|
const { container } = render(ProgressBar, { props: { item } });
|
|
|
|
expect(screen.getByText('75%')).toBeInTheDocument();
|
|
|
|
const progressBar = container.querySelector('.progress-bar-fill') as HTMLElement;
|
|
expect(progressBar).toHaveStyle({ width: '75%' });
|
|
});
|
|
|
|
it('shows progress bar for processing status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'processing',
|
|
progress: 90,
|
|
};
|
|
|
|
render(ProgressBar, { props: { item } });
|
|
|
|
expect(screen.getByText('90%')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows success message for complete status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'complete',
|
|
progress: 100,
|
|
};
|
|
|
|
render(ProgressBar, { props: { item } });
|
|
|
|
expect(screen.getByText(/upload complete/i)).toBeInTheDocument();
|
|
expect(screen.queryByText(/\d+%/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error message for error status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'error',
|
|
progress: 0,
|
|
error: 'File too large',
|
|
};
|
|
|
|
render(ProgressBar, { props: { item } });
|
|
|
|
expect(screen.getByText('File too large')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows close button for complete status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'complete',
|
|
progress: 100,
|
|
};
|
|
|
|
render(ProgressBar, { props: { item } });
|
|
|
|
const closeButton = screen.getByRole('button', { name: /remove/i });
|
|
expect(closeButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows close button for error status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'error',
|
|
progress: 0,
|
|
error: 'Failed',
|
|
};
|
|
|
|
render(ProgressBar, { props: { item } });
|
|
|
|
const closeButton = screen.getByRole('button', { name: /remove/i });
|
|
expect(closeButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides close button for uploading status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'uploading',
|
|
progress: 50,
|
|
};
|
|
|
|
render(ProgressBar, { props: { item } });
|
|
|
|
const closeButton = screen.queryByRole('button', { name: /remove/i });
|
|
expect(closeButton).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Status Icons', () => {
|
|
it('shows correct icon for uploading status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'uploading',
|
|
progress: 50,
|
|
};
|
|
|
|
const { container } = render(ProgressBar, { props: { item } });
|
|
|
|
const statusIcon = container.querySelector('.status-icon');
|
|
expect(statusIcon).toHaveTextContent('⟳');
|
|
});
|
|
|
|
it('shows correct icon for processing status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'processing',
|
|
progress: 90,
|
|
};
|
|
|
|
const { container } = render(ProgressBar, { props: { item } });
|
|
|
|
const statusIcon = container.querySelector('.status-icon');
|
|
expect(statusIcon).toHaveTextContent('⟳');
|
|
});
|
|
|
|
it('shows correct icon for complete status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'complete',
|
|
progress: 100,
|
|
};
|
|
|
|
const { container } = render(ProgressBar, { props: { item } });
|
|
|
|
const statusIcon = container.querySelector('.status-icon');
|
|
expect(statusIcon).toHaveTextContent('✓');
|
|
});
|
|
|
|
it('shows correct icon for error status', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'error',
|
|
progress: 0,
|
|
error: 'Failed',
|
|
};
|
|
|
|
const { container } = render(ProgressBar, { props: { item } });
|
|
|
|
const statusIcon = container.querySelector('.status-icon');
|
|
expect(statusIcon).toHaveTextContent('✗');
|
|
});
|
|
});
|
|
|
|
describe('Remove Functionality', () => {
|
|
it('removes item from store when close button is clicked', async () => {
|
|
const imageStore = await import('$lib/stores/images');
|
|
const updateFn = vi.fn((callback) => callback([]));
|
|
imageStore.uploadProgress.update = updateFn;
|
|
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'complete',
|
|
progress: 100,
|
|
};
|
|
|
|
render(ProgressBar, { props: { item } });
|
|
|
|
const closeButton = screen.getByRole('button', { name: /remove/i });
|
|
await fireEvent.click(closeButton);
|
|
|
|
expect(updateFn).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Progress Display', () => {
|
|
it('shows progress percentage correctly', () => {
|
|
const testCases = [0, 25, 50, 75, 100];
|
|
|
|
testCases.forEach((progress) => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'test.jpg',
|
|
status: 'uploading',
|
|
progress,
|
|
};
|
|
|
|
const { unmount, container } = render(ProgressBar, { props: { item } });
|
|
|
|
expect(screen.getByText(`${progress}%`)).toBeInTheDocument();
|
|
|
|
const progressBar = container.querySelector('.progress-bar-fill') as HTMLElement;
|
|
expect(progressBar).toHaveStyle({ width: `${progress}%` });
|
|
|
|
unmount();
|
|
});
|
|
});
|
|
|
|
it('truncates long filenames', () => {
|
|
const item: ImageUploadProgress = {
|
|
filename: 'very-long-filename-that-should-be-truncated-with-ellipsis.jpg',
|
|
status: 'uploading',
|
|
progress: 50,
|
|
};
|
|
|
|
const { container } = render(ProgressBar, { props: { item } });
|
|
|
|
const filenameElement = container.querySelector('.filename') as HTMLElement;
|
|
expect(filenameElement).toHaveStyle({
|
|
overflow: 'hidden',
|
|
'text-overflow': 'ellipsis',
|
|
'white-space': 'nowrap',
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ErrorDisplay', () => {
|
|
describe('Rendering', () => {
|
|
it('renders error message', () => {
|
|
render(ErrorDisplay, { props: { error: 'Upload failed' } });
|
|
|
|
expect(screen.getByText('Upload failed')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders with error icon', () => {
|
|
const { container } = render(ErrorDisplay, { props: { error: 'Test error' } });
|
|
|
|
const icon = container.querySelector('.error-icon svg');
|
|
expect(icon).toBeInTheDocument();
|
|
});
|
|
|
|
it('has proper ARIA role', () => {
|
|
render(ErrorDisplay, { props: { error: 'Test error' } });
|
|
|
|
const errorDisplay = screen.getByRole('alert');
|
|
expect(errorDisplay).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows dismiss button by default', () => {
|
|
render(ErrorDisplay, { props: { error: 'Test error' } });
|
|
|
|
const dismissButton = screen.getByRole('button', { name: /dismiss error/i });
|
|
expect(dismissButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides dismiss button when dismissible is false', () => {
|
|
render(ErrorDisplay, { props: { error: 'Test error', dismissible: false } });
|
|
|
|
const dismissButton = screen.queryByRole('button', { name: /dismiss error/i });
|
|
expect(dismissButton).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Dismiss Functionality', () => {
|
|
it('dispatches dismiss event when button is clicked', async () => {
|
|
const { component } = render(ErrorDisplay, { props: { error: 'Test error' } });
|
|
|
|
const dismissHandler = vi.fn();
|
|
component.$on('dismiss', dismissHandler);
|
|
|
|
const dismissButton = screen.getByRole('button', { name: /dismiss error/i });
|
|
await fireEvent.click(dismissButton);
|
|
|
|
expect(dismissHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not dispatch dismiss event when dismissible is false', () => {
|
|
const { component } = render(ErrorDisplay, {
|
|
props: { error: 'Test error', dismissible: false },
|
|
});
|
|
|
|
const dismissHandler = vi.fn();
|
|
component.$on('dismiss', dismissHandler);
|
|
|
|
// No dismiss button should exist
|
|
const dismissButton = screen.queryByRole('button', { name: /dismiss error/i });
|
|
expect(dismissButton).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Error Messages', () => {
|
|
it('handles short error messages', () => {
|
|
render(ErrorDisplay, { props: { error: 'Error' } });
|
|
|
|
expect(screen.getByText('Error')).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles long error messages', () => {
|
|
const longError =
|
|
'This is a very long error message that contains detailed information about what went wrong during the upload process. It should be displayed correctly with proper line wrapping.';
|
|
|
|
render(ErrorDisplay, { props: { error: longError } });
|
|
|
|
expect(screen.getByText(longError)).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles error messages with special characters', () => {
|
|
const errorWithSpecialChars = "File 'test.jpg' couldn't be uploaded: size > 50MB";
|
|
|
|
render(ErrorDisplay, { props: { error: errorWithSpecialChars } });
|
|
|
|
expect(screen.getByText(errorWithSpecialChars)).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles empty error messages', () => {
|
|
render(ErrorDisplay, { props: { error: '' } });
|
|
|
|
const errorMessage = screen.getByRole('alert');
|
|
expect(errorMessage).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Styling', () => {
|
|
it('applies error styling classes', () => {
|
|
const { container } = render(ErrorDisplay, { props: { error: 'Test error' } });
|
|
|
|
const errorDisplay = container.querySelector('.error-display');
|
|
expect(errorDisplay).toBeInTheDocument();
|
|
expect(errorDisplay).toHaveClass('error-display');
|
|
});
|
|
|
|
it('has proper visual hierarchy', () => {
|
|
const { container } = render(ErrorDisplay, { props: { error: 'Test error' } });
|
|
|
|
const errorIcon = container.querySelector('.error-icon');
|
|
const errorContent = container.querySelector('.error-content');
|
|
const dismissButton = container.querySelector('.dismiss-button');
|
|
|
|
expect(errorIcon).toBeInTheDocument();
|
|
expect(errorContent).toBeInTheDocument();
|
|
expect(dismissButton).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|