import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FileDropzone } from './file-dropzone'; import { DROPZONE_CHANGE_EVENT, DROPZONE_REJECT_EVENT } from './file-dropzone.types'; const MARKUP = `
Drop here
`; function setup(): HTMLElement { document.body.innerHTML = MARKUP; return document.body.firstElementChild as HTMLElement; } function file(name: string, type = 'text/plain', size = 4): File { return new File(['x'.repeat(size)], name, { type }); } function items(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll('[data-c42-dropzone-item]')); } function dropEvent(files: File[]): Event { const event = new Event('drop', { bubbles: true, cancelable: true }); Object.defineProperty(event, 'dataTransfer', { value: { files } }); return event; } describe('FileDropzone', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('throws when the input is missing', () => { document.body.innerHTML = '
'; expect(() => new FileDropzone(document.body.firstElementChild as HTMLElement)).toThrow( /\[42\/file-dropzone\]/, ); }); it('accepts a valid file and emits change', () => { const root = setup(); const dz = new FileDropzone(root); const spy = vi.fn(); dz.on(DROPZONE_CHANGE_EVENT, spy); dz.addFiles([file('a.txt')]); expect(dz.files).toHaveLength(1); expect((spy.mock.calls[0][0] as CustomEvent).detail.files[0].name).toBe('a.txt'); }); it('rejects files over maxSize', () => { const root = setup(); const dz = new FileDropzone(root, { maxSize: 5 }); const spy = vi.fn(); dz.on(DROPZONE_REJECT_EVENT, spy); dz.addFiles([file('big.txt', 'text/plain', 10)]); expect(dz.files).toHaveLength(0); expect((spy.mock.calls[0][0] as CustomEvent).detail.reason).toBe('too-large'); }); it('rejects unaccepted types by extension and mime', () => { const root = setup(); const dz = new FileDropzone(root, { accept: '.png,image/jpeg', multiple: true }); const spy = vi.fn(); dz.on(DROPZONE_REJECT_EVENT, spy); dz.addFiles([file('doc.txt', 'text/plain')]); expect(dz.files).toHaveLength(0); expect((spy.mock.calls[0][0] as CustomEvent).detail.reason).toBe('unaccepted-type'); dz.addFiles([file('pic.png', 'image/png')]); dz.addFiles([file('photo.jpg', 'image/jpeg')]); expect(dz.files).toHaveLength(2); }); it('honors image/* wildcard', () => { const root = setup(); const dz = new FileDropzone(root, { accept: 'image/*', multiple: true }); dz.addFiles([file('a.png', 'image/png'), file('b.gif', 'image/gif'), file('c.txt', 'text/plain')]); expect(dz.files).toHaveLength(2); }); it('replaces the file in single mode', () => { const root = setup(); const dz = new FileDropzone(root); dz.addFiles([file('a.txt')]); dz.addFiles([file('b.txt')]); expect(dz.files).toHaveLength(1); expect(dz.files[0].name).toBe('b.txt'); }); it('accumulates and enforces maxFiles in multiple mode', () => { const root = setup(); const dz = new FileDropzone(root, { multiple: true, maxFiles: 2 }); const spy = vi.fn(); dz.on(DROPZONE_REJECT_EVENT, spy); dz.addFiles([file('a.txt'), file('b.txt'), file('c.txt')]); expect(dz.files).toHaveLength(2); expect((spy.mock.calls[0][0] as CustomEvent).detail.reason).toBe('max-files'); }); it('runs a custom validate hook', () => { const root = setup(); const dz = new FileDropzone(root, { validate: (f) => (f.name.startsWith('ok') ? true : 'bad name'), }); const spy = vi.fn(); dz.on(DROPZONE_REJECT_EVENT, spy); dz.addFiles([file('nope.txt')]); expect(dz.files).toHaveLength(0); expect((spy.mock.calls[0][0] as CustomEvent).detail.message).toBe('bad name'); dz.addFiles([file('ok.txt')]); expect(dz.files).toHaveLength(1); }); it('renders a list item with a remove button per file', () => { const root = setup(); const dz = new FileDropzone(root, { multiple: true }); dz.addFiles([file('a.txt'), file('b.txt')]); expect(items(root)).toHaveLength(2); expect(root.querySelector('[data-c42-dropzone-name]')!.textContent).toBe('a.txt'); }); it('removes a file via the rendered remove button', () => { const root = setup(); const dz = new FileDropzone(root, { multiple: true }); dz.addFiles([file('a.txt'), file('b.txt')]); root.querySelector('[data-c42-dropzone-remove]')!.click(); expect(dz.files).toHaveLength(1); expect(dz.files[0].name).toBe('b.txt'); }); it('accepts dropped files and toggles drag state', () => { const root = setup(); const dz = new FileDropzone(root, { multiple: true }); const zone = root.querySelector('[data-c42-dropzone-zone]')!; const over = new Event('dragover', { bubbles: true, cancelable: true }); zone.dispatchEvent(over); expect(zone.dataset.state).toBe('dragging'); zone.dispatchEvent(dropEvent([file('a.txt')])); expect(zone.dataset.state).toBe('idle'); expect(dz.files).toHaveLength(1); }); it('ignores interaction when disabled', () => { const root = setup(); const dz = new FileDropzone(root, { disabled: true }); dz.addFiles([file('a.txt')]); expect(dz.files).toHaveLength(0); expect(root.hasAttribute('data-disabled')).toBe(true); }); it('clears all files', () => { const root = setup(); const dz = new FileDropzone(root, { multiple: true }); dz.addFiles([file('a.txt'), file('b.txt')]); dz.clear(); expect(dz.files).toHaveLength(0); expect(dz.getState().count).toBe(0); }); it('stops reacting after destroy', () => { const root = setup(); const dz = new FileDropzone(root, { multiple: true }); dz.destroy(); const zone = root.querySelector('[data-c42-dropzone-zone]')!; zone.dispatchEvent(dropEvent([file('a.txt')])); expect(dz.files).toHaveLength(0); }); });