import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; import { AssetPicker, acceptFor, matchesMediaType, isStandalone } from './AssetPicker'; import type { Asset } from '../utils/assets'; vi.mock('../utils/assets', () => ({ uploadAsset: vi.fn(), listAssets: vi.fn(), })); import { uploadAsset, listAssets } from '../utils/assets'; const uploadAssetMock = uploadAsset as unknown as ReturnType; const listAssetsMock = listAssets as unknown as ReturnType; const GRID_ASSETS: Asset[] = [ { name: 'a.png', url: '/storage/assets/a.png', type: 'image/png' }, { name: 'b.mp4', url: '/storage/assets/b.mp4', type: 'video/mp4' }, ]; /* ---------- DOM test harness (no @testing-library/react in this repo, see src/utils/assets.test.ts / navColorFields.test.ts for the plain-logic pattern used elsewhere; component-level interaction here uses react-dom/client + react-dom/test-utils `act`, both already transitive deps of `react-dom`, so no new devDependency is introduced). ---------- */ let container: HTMLDivElement; let root: Root; function render(ui: React.ReactElement) { container = document.createElement('div'); document.body.appendChild(container); act(() => { root = createRoot(container); root.render(ui); }); } function unmount() { act(() => { root.unmount(); }); container.remove(); } function click(el: Element | null) { if (!el) throw new Error('element not found'); act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); }); } function setValue(input: HTMLInputElement, value: string) { const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!; setter.call(input, value); input.dispatchEvent(new Event('input', { bubbles: true })); } function q(testId: string): T | null { return container.querySelector(`[data-testid="${testId}"]`); } describe('AssetPicker pure helpers', () => { test('acceptFor maps mediaType to the file input accept attribute', () => { expect(acceptFor('image')).toBe('image/*'); expect(acceptFor('video')).toBe('video/*'); expect(acceptFor('any')).toBeUndefined(); }); test('matchesMediaType guards drag-drop uploads by mediaType', () => { expect(matchesMediaType('image/png', 'image')).toBe(true); expect(matchesMediaType('video/mp4', 'image')).toBe(false); expect(matchesMediaType('video/mp4', 'video')).toBe(true); expect(matchesMediaType('application/pdf', 'any')).toBe(true); }); test('isStandalone reflects presence of window.WHP_CONFIG', () => { delete (window as any).WHP_CONFIG; expect(isStandalone()).toBe(true); (window as any).WHP_CONFIG = { apiUrl: 'x', siteId: 1, csrfToken: 't' }; expect(isStandalone()).toBe(false); delete (window as any).WHP_CONFIG; }); }); describe('AssetPicker component', () => { beforeEach(() => { uploadAssetMock.mockReset(); listAssetsMock.mockReset(); listAssetsMock.mockResolvedValue(GRID_ASSETS); delete (window as any).WHP_CONFIG; }); afterEach(() => { if (container) unmount(); delete (window as any).WHP_CONFIG; }); test('renders the current value as a preview thumbnail (full variant)', () => { render(); const preview = q('asset-picker-preview'); expect(preview).not.toBeNull(); expect(preview!.src).toContain('/storage/assets/existing.png'); expect(q('asset-picker-dropzone')).toBeNull(); }); test('renders a dropzone (no preview) when value is empty', () => { render(); expect(q('asset-picker-dropzone')).not.toBeNull(); expect(q('asset-picker-preview')).toBeNull(); }); test('remove button clears the value via onChange("")', () => { const onChange = vi.fn(); render(); click(q('asset-picker-remove')); expect(onChange).toHaveBeenCalledWith(''); }); test('Browse control is absent when window.WHP_CONFIG is undefined (standalone)', () => { render(); expect(q('asset-picker-browse')).toBeNull(); }); test('Browse control is present and lists assets filtered by mediaType when WHP_CONFIG is set', async () => { (window as any).WHP_CONFIG = { apiUrl: 'https://x', siteId: 1, csrfToken: 't' }; render(); const browseBtn = q('asset-picker-browse'); expect(browseBtn).not.toBeNull(); await act(async () => { click(browseBtn); await Promise.resolve(); }); expect(listAssetsMock).toHaveBeenCalledWith('video'); expect(q('asset-picker-grid')).not.toBeNull(); }); test('selecting a grid asset invokes onChange with that asset url and closes the grid', async () => { (window as any).WHP_CONFIG = { apiUrl: 'https://x', siteId: 1, csrfToken: 't' }; const onChange = vi.fn(); render(); await act(async () => { click(q('asset-picker-browse')); await Promise.resolve(); }); expect(q('asset-picker-grid')).not.toBeNull(); click(q(`asset-picker-thumb-${GRID_ASSETS[0].name}`)); expect(onChange).toHaveBeenCalledWith(GRID_ASSETS[0].url); expect(q('asset-picker-grid')).toBeNull(); }); test('URL apply invokes onChange with the typed url (full variant Apply button)', () => { const onChange = vi.fn(); render(); const input = q('asset-picker-url-input')!; setValue(input, 'https://example.com/pic.jpg'); click(q('asset-picker-apply')); expect(onChange).toHaveBeenCalledWith('https://example.com/pic.jpg'); }); test('uploading a file calls uploadAsset and applies the resolved url via onChange', async () => { uploadAssetMock.mockResolvedValue('/storage/assets/uploaded.png'); const onChange = vi.fn(); render(); const fileInput = q('asset-picker-file-input')!; const file = new File(['x'], 'photo.png', { type: 'image/png' }); const dt = { files: [file] }; Object.defineProperty(fileInput, 'files', { value: dt.files }); await act(async () => { fileInput.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); await Promise.resolve(); }); expect(uploadAssetMock).toHaveBeenCalledWith(file); expect(onChange).toHaveBeenCalledWith('/storage/assets/uploaded.png'); }); test('variant="compact" renders the compact single-row structure instead of the full structure', () => { render(); expect(q('asset-picker-compact')).not.toBeNull(); expect(q('asset-picker-full')).toBeNull(); expect(q('asset-picker-dropzone')).toBeNull(); expect(q('asset-picker-url-input')).not.toBeNull(); expect(q('asset-picker-upload')).not.toBeNull(); }); test('compact variant: URL input applies onChange on blur', () => { const onChange = vi.fn(); render(); const input = q('asset-picker-url-input')!; setValue(input, 'https://example.com/vid.mp4'); // React delegates onBlur via the bubbling 'focusout' event (blur itself doesn't bubble). act(() => { input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); expect(onChange).toHaveBeenCalledWith('https://example.com/vid.mp4'); }); test('mediaType="video" sets file input accept to video/* and mediaType="any" omits accept', () => { render(); expect(q('asset-picker-file-input')!.accept).toBe('video/*'); unmount(); render(); expect(q('asset-picker-file-input')!.accept).toBe(''); }); });