import React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, fireEvent, act } from '@testing-library/react'; import { PixelFileUpload } from '../../forms/PixelFileUpload'; /* ────────────────────────────────────────────────────────────────────────── Helpers — File polyfill in jsdom is fine; we just need stable size. ────────────────────────────────────────────────────────────────────────── */ function mkFile(name: string, size: number, type = 'text/plain'): File { const f = new File(['x'], name, { type }); Object.defineProperty(f, 'size', { value: size }); return f; } function getInput(container: HTMLElement): HTMLInputElement { // The visually-hidden file input mirror. return container.querySelector('input[type="file"]') as HTMLInputElement; } function getDropzone(container: HTMLElement): HTMLElement { return container.querySelector('[data-pxl-dropzone="true"]') as HTMLElement; } function fireFilesOnInput(input: HTMLInputElement, files: File[]) { Object.defineProperty(input, 'files', { configurable: true, value: files, }); fireEvent.change(input); } function fireDrop(zone: HTMLElement, files: File[]) { const dataTransfer = { files, items: files.map((f) => ({ kind: 'file', getAsFile: () => f })), types: ['Files'] }; fireEvent.drop(zone, { dataTransfer }); } describe('PixelFileUpload', () => { it('renders dropzone with hint', () => { const { container, getByText } = render( , ); expect(getDropzone(container)).toBeTruthy(); expect(getByText('Up to 5MB')).toBeTruthy(); }); it('clicking dropzone triggers file input', () => { const { container } = render(); const input = getInput(container); const clickSpy = vi.spyOn(input, 'click'); const zone = getDropzone(container); act(() => { fireEvent.click(zone); }); expect(clickSpy).toHaveBeenCalled(); }); it('dropped files appear in list', () => { const onChange = vi.fn(); const { container, getByText } = render( , ); const zone = getDropzone(container); const files = [mkFile('a.txt', 100), mkFile('b.txt', 200)]; act(() => { fireDrop(zone, files); }); expect(onChange).toHaveBeenCalled(); const last = onChange.mock.calls[onChange.mock.calls.length - 1][0]; expect(last).toHaveLength(2); expect(getByText('a.txt')).toBeTruthy(); expect(getByText('b.txt')).toBeTruthy(); }); it('oversized file goes to onReject', () => { const onChange = vi.fn(); const onReject = vi.fn(); const { container } = render( , ); const input = getInput(container); const big = mkFile('big.txt', 1000); const ok = mkFile('ok.txt', 100); act(() => { fireFilesOnInput(input, [ok, big]); }); expect(onReject).toHaveBeenCalled(); const rejections = onReject.mock.calls[0][0]; expect(rejections).toHaveLength(1); expect(rejections[0].file.name).toBe('big.txt'); expect(rejections[0].reasons).toContain('size'); // onChange got only the OK file const last = onChange.mock.calls[onChange.mock.calls.length - 1][0]; expect(last.map((f: File) => f.name)).toEqual(['ok.txt']); }); it('maxFiles cap drops extras to onReject', () => { const onChange = vi.fn(); const onReject = vi.fn(); const { container } = render( , ); const input = getInput(container); const files = [mkFile('a.txt', 10), mkFile('b.txt', 10), mkFile('c.txt', 10)]; act(() => { fireFilesOnInput(input, files); }); const last = onChange.mock.calls[onChange.mock.calls.length - 1][0]; expect(last).toHaveLength(2); expect(onReject).toHaveBeenCalled(); const rejections = onReject.mock.calls[0][0]; expect(rejections).toHaveLength(1); expect(rejections[0].file.name).toBe('c.txt'); expect(rejections[0].reasons).toContain('maxFiles'); }); it('remove button on item removes from value', () => { const onChange = vi.fn(); const a = mkFile('a.txt', 10); const b = mkFile('b.txt', 20); const { container, getAllByLabelText } = render( , ); // value is controlled; should render 2 items expect(container.querySelectorAll('[data-pxl-file-item="true"]')).toHaveLength(2); const removeButtons = getAllByLabelText(/remove/i); act(() => { fireEvent.click(removeButtons[0]); }); const last = onChange.mock.calls[onChange.mock.calls.length - 1][0]; expect(last).toHaveLength(1); expect(last[0].name).toBe('b.txt'); }); it('multiple=false replaces value with single file', () => { const onChange = vi.fn(); const { container } = render( , ); const input = getInput(container); const a = mkFile('a.txt', 10); const b = mkFile('b.txt', 20); act(() => { fireFilesOnInput(input, [a]); }); let last = onChange.mock.calls[onChange.mock.calls.length - 1][0]; expect(last).toHaveLength(1); expect(last[0].name).toBe('a.txt'); // Selecting another single file replaces, doesn't append. act(() => { fireFilesOnInput(input, [b]); }); last = onChange.mock.calls[onChange.mock.calls.length - 1][0]; expect(last).toHaveLength(1); expect(last[0].name).toBe('b.txt'); }); }); describe('PixelFileUpload — single file-dialog activation (FieldShell label regression)', () => { it('one click on the dropzone triggers the hidden input exactly once', () => { // FieldShell used to wrap the whole field in a