phase 6
This commit is contained in:
627
frontend/tests/canvas/controls.test.ts
Normal file
627
frontend/tests/canvas/controls.test.ts
Normal file
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* Tests for canvas controls (pan, zoom, rotate, reset, fit)
|
||||
* Tests viewport store and control functions
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { get } from 'svelte/store';
|
||||
import { viewport, isViewportDefault, isZoomMin, isZoomMax } from '$lib/stores/viewport';
|
||||
import { panTo, panBy } from '$lib/canvas/controls/pan';
|
||||
import { zoomTo, zoomBy, zoomIn, zoomOut } from '$lib/canvas/controls/zoom';
|
||||
import {
|
||||
rotateTo,
|
||||
rotateBy,
|
||||
rotateClockwise,
|
||||
rotateCounterClockwise,
|
||||
resetRotation,
|
||||
rotateTo90,
|
||||
rotateTo180,
|
||||
rotateTo270,
|
||||
} from '$lib/canvas/controls/rotate';
|
||||
import { resetCamera, resetPan, resetZoom } from '$lib/canvas/controls/reset';
|
||||
|
||||
describe('Viewport Store', () => {
|
||||
beforeEach(() => {
|
||||
// Reset viewport to default state before each test
|
||||
viewport.reset();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('starts with default values', () => {
|
||||
const state = get(viewport);
|
||||
expect(state).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1.0,
|
||||
rotation: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('isViewportDefault is true at initialization', () => {
|
||||
expect(get(isViewportDefault)).toBe(true);
|
||||
});
|
||||
|
||||
it('provides viewport bounds', () => {
|
||||
const bounds = viewport.getBounds();
|
||||
expect(bounds).toEqual({
|
||||
minZoom: 0.1,
|
||||
maxZoom: 5.0,
|
||||
minRotation: 0,
|
||||
maxRotation: 360,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pan Operations', () => {
|
||||
it('sets pan position', () => {
|
||||
viewport.setPan(100, 200);
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(100);
|
||||
expect(state.y).toBe(200);
|
||||
});
|
||||
|
||||
it('pans by delta', () => {
|
||||
viewport.setPan(50, 50);
|
||||
viewport.panBy(25, 30);
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(75);
|
||||
expect(state.y).toBe(80);
|
||||
});
|
||||
|
||||
it('allows negative pan values', () => {
|
||||
viewport.setPan(-100, -200);
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(-100);
|
||||
expect(state.y).toBe(-200);
|
||||
});
|
||||
|
||||
it('handles large pan values', () => {
|
||||
viewport.setPan(100000, 100000);
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(100000);
|
||||
expect(state.y).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zoom Operations', () => {
|
||||
it('sets zoom level', () => {
|
||||
viewport.setZoom(2.0);
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBe(2.0);
|
||||
});
|
||||
|
||||
it('clamps zoom to minimum', () => {
|
||||
viewport.setZoom(0.05);
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBe(0.1);
|
||||
});
|
||||
|
||||
it('clamps zoom to maximum', () => {
|
||||
viewport.setZoom(10.0);
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBe(5.0);
|
||||
});
|
||||
|
||||
it('zooms by factor', () => {
|
||||
viewport.setZoom(1.0);
|
||||
viewport.zoomBy(2.0);
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBe(2.0);
|
||||
});
|
||||
|
||||
it('zooms to center point', () => {
|
||||
viewport.setZoom(1.0, 100, 100);
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBe(1.0);
|
||||
// Position should remain at center
|
||||
});
|
||||
|
||||
it('isZoomMin reflects minimum zoom', () => {
|
||||
viewport.setZoom(0.1);
|
||||
expect(get(isZoomMin)).toBe(true);
|
||||
|
||||
viewport.setZoom(1.0);
|
||||
expect(get(isZoomMin)).toBe(false);
|
||||
});
|
||||
|
||||
it('isZoomMax reflects maximum zoom', () => {
|
||||
viewport.setZoom(5.0);
|
||||
expect(get(isZoomMax)).toBe(true);
|
||||
|
||||
viewport.setZoom(1.0);
|
||||
expect(get(isZoomMax)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rotation Operations', () => {
|
||||
it('sets rotation', () => {
|
||||
viewport.setRotation(45);
|
||||
const state = get(viewport);
|
||||
expect(state.rotation).toBe(45);
|
||||
});
|
||||
|
||||
it('normalizes rotation to 0-360', () => {
|
||||
viewport.setRotation(450);
|
||||
expect(get(viewport).rotation).toBe(90);
|
||||
|
||||
viewport.setRotation(-90);
|
||||
expect(get(viewport).rotation).toBe(270);
|
||||
});
|
||||
|
||||
it('rotates by delta', () => {
|
||||
viewport.setRotation(45);
|
||||
viewport.rotateBy(15);
|
||||
expect(get(viewport).rotation).toBe(60);
|
||||
});
|
||||
|
||||
it('handles negative rotation delta', () => {
|
||||
viewport.setRotation(45);
|
||||
viewport.rotateBy(-15);
|
||||
expect(get(viewport).rotation).toBe(30);
|
||||
});
|
||||
|
||||
it('wraps rotation around 360', () => {
|
||||
viewport.setRotation(350);
|
||||
viewport.rotateBy(20);
|
||||
expect(get(viewport).rotation).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset Operations', () => {
|
||||
it('resets viewport to default', () => {
|
||||
viewport.setPan(100, 100);
|
||||
viewport.setZoom(2.0);
|
||||
viewport.setRotation(45);
|
||||
|
||||
viewport.reset();
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1.0,
|
||||
rotation: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('reset makes isViewportDefault true', () => {
|
||||
viewport.setPan(100, 100);
|
||||
expect(get(isViewportDefault)).toBe(false);
|
||||
|
||||
viewport.reset();
|
||||
expect(get(isViewportDefault)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fit to Screen', () => {
|
||||
it('fits content to screen with default padding', () => {
|
||||
viewport.fitToScreen(800, 600, 1024, 768);
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBeGreaterThan(0);
|
||||
expect(state.rotation).toBe(0); // Rotation reset when fitting
|
||||
});
|
||||
|
||||
it('fits content with custom padding', () => {
|
||||
viewport.fitToScreen(800, 600, 1024, 768, 100);
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles oversized content', () => {
|
||||
viewport.fitToScreen(2000, 1500, 1024, 768);
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBeLessThan(1.0);
|
||||
});
|
||||
|
||||
it('handles undersized content', () => {
|
||||
viewport.fitToScreen(100, 100, 1024, 768);
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBeGreaterThan(1.0);
|
||||
});
|
||||
|
||||
it('respects maximum zoom when fitting', () => {
|
||||
// Very small content that would zoom beyond max
|
||||
viewport.fitToScreen(10, 10, 1024, 768);
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBeLessThanOrEqual(5.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Load State', () => {
|
||||
it('loads partial state', () => {
|
||||
viewport.loadState({ x: 100, y: 200 });
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(100);
|
||||
expect(state.y).toBe(200);
|
||||
expect(state.zoom).toBe(1.0); // Unchanged
|
||||
expect(state.rotation).toBe(0); // Unchanged
|
||||
});
|
||||
|
||||
it('loads complete state', () => {
|
||||
viewport.loadState({
|
||||
x: 100,
|
||||
y: 200,
|
||||
zoom: 2.5,
|
||||
rotation: 90,
|
||||
});
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state).toEqual({
|
||||
x: 100,
|
||||
y: 200,
|
||||
zoom: 2.5,
|
||||
rotation: 90,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps loaded zoom to bounds', () => {
|
||||
viewport.loadState({ zoom: 10.0 });
|
||||
expect(get(viewport).zoom).toBe(5.0);
|
||||
|
||||
viewport.loadState({ zoom: 0.01 });
|
||||
expect(get(viewport).zoom).toBe(0.1);
|
||||
});
|
||||
|
||||
it('normalizes loaded rotation', () => {
|
||||
viewport.loadState({ rotation: 450 });
|
||||
expect(get(viewport).rotation).toBe(90);
|
||||
|
||||
viewport.loadState({ rotation: -45 });
|
||||
expect(get(viewport).rotation).toBe(315);
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Subscription', () => {
|
||||
it('notifies subscribers on pan changes', () => {
|
||||
const subscriber = vi.fn();
|
||||
const unsubscribe = viewport.subscribe(subscriber);
|
||||
|
||||
viewport.setPan(100, 100);
|
||||
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('notifies subscribers on zoom changes', () => {
|
||||
const subscriber = vi.fn();
|
||||
const unsubscribe = viewport.subscribe(subscriber);
|
||||
|
||||
viewport.setZoom(2.0);
|
||||
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('notifies subscribers on rotation changes', () => {
|
||||
const subscriber = vi.fn();
|
||||
const unsubscribe = viewport.subscribe(subscriber);
|
||||
|
||||
viewport.setRotation(45);
|
||||
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pan Controls', () => {
|
||||
beforeEach(() => {
|
||||
viewport.reset();
|
||||
});
|
||||
|
||||
describe('Programmatic Pan', () => {
|
||||
it('panTo sets absolute position', () => {
|
||||
panTo(100, 200);
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(100);
|
||||
expect(state.y).toBe(200);
|
||||
});
|
||||
|
||||
it('panBy moves relative to current position', () => {
|
||||
panTo(50, 50);
|
||||
panBy(25, 30);
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(75);
|
||||
expect(state.y).toBe(80);
|
||||
});
|
||||
|
||||
it('panBy with negative deltas', () => {
|
||||
panTo(100, 100);
|
||||
panBy(-50, -50);
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(50);
|
||||
expect(state.y).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zoom Controls', () => {
|
||||
beforeEach(() => {
|
||||
viewport.reset();
|
||||
});
|
||||
|
||||
describe('Programmatic Zoom', () => {
|
||||
it('zoomTo sets absolute zoom level', () => {
|
||||
zoomTo(2.5);
|
||||
|
||||
expect(get(viewport).zoom).toBe(2.5);
|
||||
});
|
||||
|
||||
it('zoomBy multiplies current zoom', () => {
|
||||
zoomTo(2.0);
|
||||
zoomBy(1.5);
|
||||
|
||||
expect(get(viewport).zoom).toBe(3.0);
|
||||
});
|
||||
|
||||
it('zoomIn increases zoom', () => {
|
||||
const initialZoom = get(viewport).zoom;
|
||||
zoomIn();
|
||||
|
||||
expect(get(viewport).zoom).toBeGreaterThan(initialZoom);
|
||||
});
|
||||
|
||||
it('zoomOut decreases zoom', () => {
|
||||
zoomTo(2.0);
|
||||
const initialZoom = get(viewport).zoom;
|
||||
zoomOut();
|
||||
|
||||
expect(get(viewport).zoom).toBeLessThan(initialZoom);
|
||||
});
|
||||
|
||||
it('zoomIn respects maximum zoom', () => {
|
||||
zoomTo(4.9);
|
||||
zoomIn();
|
||||
|
||||
expect(get(viewport).zoom).toBeLessThanOrEqual(5.0);
|
||||
});
|
||||
|
||||
it('zoomOut respects minimum zoom', () => {
|
||||
zoomTo(0.15);
|
||||
zoomOut();
|
||||
|
||||
expect(get(viewport).zoom).toBeGreaterThanOrEqual(0.1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rotate Controls', () => {
|
||||
beforeEach(() => {
|
||||
viewport.reset();
|
||||
});
|
||||
|
||||
describe('Programmatic Rotation', () => {
|
||||
it('rotateTo sets absolute rotation', () => {
|
||||
rotateTo(90);
|
||||
|
||||
expect(get(viewport).rotation).toBe(90);
|
||||
});
|
||||
|
||||
it('rotateBy adds to current rotation', () => {
|
||||
rotateTo(45);
|
||||
rotateBy(15);
|
||||
|
||||
expect(get(viewport).rotation).toBe(60);
|
||||
});
|
||||
|
||||
it('rotateClockwise rotates by step', () => {
|
||||
rotateClockwise();
|
||||
|
||||
// Default step is 15 degrees
|
||||
expect(get(viewport).rotation).toBe(15);
|
||||
});
|
||||
|
||||
it('rotateCounterClockwise rotates by negative step', () => {
|
||||
rotateTo(30);
|
||||
rotateCounterClockwise();
|
||||
|
||||
// Default step is 15 degrees
|
||||
expect(get(viewport).rotation).toBe(15);
|
||||
});
|
||||
|
||||
it('resetRotation sets to 0', () => {
|
||||
rotateTo(90);
|
||||
resetRotation();
|
||||
|
||||
expect(get(viewport).rotation).toBe(0);
|
||||
});
|
||||
|
||||
it('rotateTo90 sets to 90 degrees', () => {
|
||||
rotateTo90();
|
||||
|
||||
expect(get(viewport).rotation).toBe(90);
|
||||
});
|
||||
|
||||
it('rotateTo180 sets to 180 degrees', () => {
|
||||
rotateTo180();
|
||||
|
||||
expect(get(viewport).rotation).toBe(180);
|
||||
});
|
||||
|
||||
it('rotateTo270 sets to 270 degrees', () => {
|
||||
rotateTo270();
|
||||
|
||||
expect(get(viewport).rotation).toBe(270);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset Controls', () => {
|
||||
beforeEach(() => {
|
||||
// Set non-default values
|
||||
viewport.setPan(100, 200);
|
||||
viewport.setZoom(2.5);
|
||||
viewport.setRotation(90);
|
||||
});
|
||||
|
||||
describe('Selective Reset', () => {
|
||||
it('resetPan only resets position', () => {
|
||||
resetPan();
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(0);
|
||||
expect(state.y).toBe(0);
|
||||
expect(state.zoom).toBe(2.5); // Unchanged
|
||||
expect(state.rotation).toBe(90); // Unchanged
|
||||
});
|
||||
|
||||
it('resetZoom only resets zoom', () => {
|
||||
resetZoom();
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(100); // Unchanged
|
||||
expect(state.y).toBe(200); // Unchanged
|
||||
expect(state.zoom).toBe(1.0);
|
||||
expect(state.rotation).toBe(90); // Unchanged
|
||||
});
|
||||
|
||||
it('resetRotation (from reset controls) only resets rotation', () => {
|
||||
resetRotation();
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(100); // Unchanged
|
||||
expect(state.y).toBe(200); // Unchanged
|
||||
expect(state.zoom).toBe(2.5); // Unchanged
|
||||
expect(state.rotation).toBe(0);
|
||||
});
|
||||
|
||||
it('resetCamera resets everything', () => {
|
||||
resetCamera();
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1.0,
|
||||
rotation: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Viewport State Serialization', () => {
|
||||
beforeEach(() => {
|
||||
viewport.reset();
|
||||
});
|
||||
|
||||
it('serializes viewport state to JSON', async () => {
|
||||
const { serializeViewportState } = await import('$lib/stores/viewport');
|
||||
|
||||
viewport.setPan(100, 200);
|
||||
viewport.setZoom(2.0);
|
||||
viewport.setRotation(45);
|
||||
|
||||
const state = get(viewport);
|
||||
const serialized = serializeViewportState(state);
|
||||
|
||||
expect(serialized).toBe(JSON.stringify({ x: 100, y: 200, zoom: 2, rotation: 45 }));
|
||||
});
|
||||
|
||||
it('deserializes viewport state from JSON', async () => {
|
||||
const { deserializeViewportState } = await import('$lib/stores/viewport');
|
||||
|
||||
const json = JSON.stringify({ x: 100, y: 200, zoom: 2.5, rotation: 90 });
|
||||
const state = deserializeViewportState(json);
|
||||
|
||||
expect(state).toEqual({
|
||||
x: 100,
|
||||
y: 200,
|
||||
zoom: 2.5,
|
||||
rotation: 90,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles invalid JSON gracefully', async () => {
|
||||
const { deserializeViewportState } = await import('$lib/stores/viewport');
|
||||
|
||||
const state = deserializeViewportState('invalid json');
|
||||
|
||||
// Should return default state
|
||||
expect(state).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1.0,
|
||||
rotation: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates deserialized values', async () => {
|
||||
const { deserializeViewportState } = await import('$lib/stores/viewport');
|
||||
|
||||
const json = JSON.stringify({ x: 100, y: 200, zoom: 10.0, rotation: 450 });
|
||||
const state = deserializeViewportState(json);
|
||||
|
||||
// Zoom should be clamped to max
|
||||
expect(state.zoom).toBe(5.0);
|
||||
|
||||
// Rotation should be normalized to 0-360
|
||||
expect(state.rotation).toBe(90);
|
||||
});
|
||||
|
||||
it('handles missing fields in JSON', async () => {
|
||||
const { deserializeViewportState } = await import('$lib/stores/viewport');
|
||||
|
||||
const json = JSON.stringify({ x: 100 });
|
||||
const state = deserializeViewportState(json);
|
||||
|
||||
expect(state.x).toBe(100);
|
||||
expect(state.y).toBe(0); // Default
|
||||
expect(state.zoom).toBe(1.0); // Default
|
||||
expect(state.rotation).toBe(0); // Default
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
viewport.reset();
|
||||
});
|
||||
|
||||
it('complex viewport manipulation sequence', () => {
|
||||
// Pan
|
||||
viewport.setPan(100, 100);
|
||||
|
||||
// Zoom
|
||||
viewport.setZoom(2.0);
|
||||
|
||||
// Rotate
|
||||
viewport.setRotation(45);
|
||||
|
||||
// Pan by delta
|
||||
viewport.panBy(50, 50);
|
||||
|
||||
const state = get(viewport);
|
||||
expect(state.x).toBe(150);
|
||||
expect(state.y).toBe(150);
|
||||
expect(state.zoom).toBe(2.0);
|
||||
expect(state.rotation).toBe(45);
|
||||
});
|
||||
|
||||
it('reset after complex manipulation', () => {
|
||||
viewport.setPan(100, 100);
|
||||
viewport.setZoom(3.0);
|
||||
viewport.setRotation(180);
|
||||
|
||||
viewport.reset();
|
||||
|
||||
expect(get(isViewportDefault)).toBe(true);
|
||||
});
|
||||
|
||||
it('multiple zoom operations maintain center', () => {
|
||||
viewport.setZoom(2.0, 500, 500);
|
||||
viewport.setZoom(1.5, 500, 500);
|
||||
|
||||
// Position should adjust to keep point at 500,500 centered
|
||||
const state = get(viewport);
|
||||
expect(state.zoom).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
997
frontend/tests/components/upload.test.ts
Normal file
997
frontend/tests/components/upload.test.ts
Normal file
@@ -0,0 +1,997 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user