Files
familienarchiv/frontend/src/routes/DropZone.svelte.test.ts
Marcel 1bcce359e1 test(routes): expand DropZone coverage for drag/drop branches
Adds drag-over and drag-leave styling, drop with no files, multiple
invalid files, mixed valid+invalid files, non-Enter keydown ignore,
window-level dragenter/dragleave with and without 'Files' types,
counter underflow guard.

16 tests, +9 covered branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00

193 lines
7.6 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
const { default: DropZone } = await import('./DropZone.svelte');
afterEach(cleanup);
describe('DropZone', () => {
it('renders the drop hint and accepted types by default', async () => {
render(DropZone, { props: {} });
await expect.element(page.getByText(/einzeln oder mehrere/i)).toBeVisible();
await expect.element(page.getByText('PDF, JPEG, PNG, TIFF')).toBeVisible();
});
it('does not render the progress bar by default', async () => {
render(DropZone, { props: {} });
expect(document.querySelector('.bg-primary.h-full')).toBeNull();
});
it('rejects files with unaccepted MIME types and shows an error message', async () => {
render(DropZone, { props: {} });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const badFile = new File(['bad'], 'doc.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
});
Object.defineProperty(input, 'files', { value: [badFile], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
await expect.element(page.getByText(/Dateiformat nicht unterstützt/i)).toBeVisible();
});
it('accepts a PDF file as a valid type', async () => {
const onUploadComplete = vi.fn();
render(DropZone, { props: { onUploadComplete } });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const pdfFile = new File(['%PDF'], 'brief.pdf', { type: 'application/pdf' });
Object.defineProperty(input, 'files', { value: [pdfFile], writable: false });
// Just verify the change event triggers without throwing
expect(() => input.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow();
});
it('returns early when no files are selected', async () => {
render(DropZone, { props: {} });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, 'files', { value: [], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
// No error message rendered
const errors = document.querySelectorAll('.text-red-600');
expect(errors.length).toBe(0);
});
it('opens the file input when the drop zone is clicked', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(input, 'click');
dropZone.click();
expect(clickSpy).toHaveBeenCalled();
});
it('opens the file input when Enter is pressed on the drop zone', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(input, 'click');
dropZone.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(clickSpy).toHaveBeenCalled();
});
it('exposes file input as multi-file with accept whitelist', async () => {
render(DropZone, { props: {} });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input.multiple).toBe(true);
expect(input.accept).toContain('.pdf');
});
it('applies the dragging style on dragover', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
dropZone.dispatchEvent(new DragEvent('dragover', { bubbles: true }));
// The dragging class is applied via reactive isDragging
await new Promise((r) => setTimeout(r, 30));
expect(dropZone.className).toMatch(/border-primary|bg-accent-bg/);
});
it('drops dragging style on dragleave', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
dropZone.dispatchEvent(new DragEvent('dragover', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
dropZone.dispatchEvent(new DragEvent('dragleave', { bubbles: true }));
await new Promise((r) => setTimeout(r, 30));
expect(dropZone.className).not.toMatch(/^border-primary bg-accent-bg/);
});
it('handles drop event without files (no-op)', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
const dropEvent = new DragEvent('drop', { bubbles: true });
Object.defineProperty(dropEvent, 'dataTransfer', { value: { files: [] }, writable: false });
expect(() => dropZone.dispatchEvent(dropEvent)).not.toThrow();
});
it('rejects multiple invalid files and lists them all', async () => {
render(DropZone, { props: {} });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const f1 = new File(['x'], 'a.docx', { type: 'application/x-bad' });
const f2 = new File(['y'], 'b.txt', { type: 'text/plain' });
Object.defineProperty(input, 'files', { value: [f1, f2], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
await new Promise((r) => setTimeout(r, 30));
const errors = document.querySelectorAll('.text-red-600');
expect(errors.length).toBe(2);
});
it('mixes valid and invalid files without throwing during upload', async () => {
render(DropZone, { props: {} });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const f1 = new File(['x'], 'a.docx', { type: 'application/x-bad' });
const f2 = new File(['%PDF'], 'b.pdf', { type: 'application/pdf' });
Object.defineProperty(input, 'files', { value: [f1, f2], writable: false });
expect(() => input.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow();
});
it('Enter handler ignores other keys', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(input, 'click');
dropZone.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
expect(clickSpy).not.toHaveBeenCalled();
});
it('responds to window-level dragenter only when dataTransfer.types includes "Files"', async () => {
render(DropZone, { props: {} });
// Non-files dragenter should not trigger windowDragging
const evt1 = new DragEvent('dragenter', { bubbles: true });
Object.defineProperty(evt1, 'dataTransfer', { value: { types: ['text/html'] } });
window.dispatchEvent(evt1);
await new Promise((r) => setTimeout(r, 10));
// Files dragenter should — we just confirm no error
const evt2 = new DragEvent('dragenter', { bubbles: true });
Object.defineProperty(evt2, 'dataTransfer', { value: { types: ['Files'] } });
expect(() => window.dispatchEvent(evt2)).not.toThrow();
// Trailing window drop to clean up
window.dispatchEvent(new DragEvent('drop', { bubbles: true }));
});
it('window-level dragleave decrements counter without going negative', async () => {
render(DropZone, { props: {} });
// Multiple dragleaves without dragenters should not throw
expect(() => window.dispatchEvent(new DragEvent('dragleave', { bubbles: true }))).not.toThrow();
expect(() => window.dispatchEvent(new DragEvent('dragleave', { bubbles: true }))).not.toThrow();
});
});