parent
eeb168043b
commit
cab1363616
@ -0,0 +1,284 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
import { FieldProvider } from '../field/field-provider.svelte';
|
||||
|
||||
import {
|
||||
FileUploadClearTriggerProvider,
|
||||
FileUploadDropzoneProvider,
|
||||
FileUploadHiddenInputProvider,
|
||||
FileUploadItemNameProvider,
|
||||
FileUploadItemProgressProvider,
|
||||
FileUploadItemProvider,
|
||||
FileUploadItemRemoveProvider,
|
||||
FileUploadItemSizeProvider,
|
||||
FileUploadLabelProvider,
|
||||
FileUploadProvider
|
||||
} from './file-upload-provider.svelte';
|
||||
import type { FileRejection, FileRejectionReason } from './types';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
langs: {
|
||||
ts: (key: string) => key
|
||||
},
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
|
||||
vi.spyOn(FileUploadProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(FileUploadItemProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function createFileUploadOpts() {
|
||||
return {
|
||||
id: state('file-upload-root'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
files: state<File[]>([]),
|
||||
accept: state<string | undefined>(undefined),
|
||||
maxFiles: state<number | undefined>(undefined),
|
||||
maxSize: state<number | undefined>(undefined),
|
||||
minSize: state<number | undefined>(undefined),
|
||||
multiple: state(true),
|
||||
disabled: state(false),
|
||||
required: state(false),
|
||||
name: state<string | undefined>(undefined),
|
||||
allowDrop: state(true),
|
||||
preventDocumentDrop: state(true),
|
||||
validate: state<
|
||||
| ((
|
||||
file: File,
|
||||
currentFiles: File[]
|
||||
) => FileRejectionReason[] | { reasons: FileRejectionReason[]; message?: string } | null)
|
||||
| undefined
|
||||
>(undefined),
|
||||
onReject: state<((rejections: FileRejection[]) => void) | undefined>(undefined),
|
||||
onFilesChange: state<((files: File[]) => void) | undefined>(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function file(name: string, body: string, type: string): File {
|
||||
return new File([body], name, { type });
|
||||
}
|
||||
|
||||
describe('FileUploadProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('accepts valid files and reports typed, size, duplicate and custom rejections', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createFileUploadOpts();
|
||||
const onFilesChange = vi.fn();
|
||||
const onReject = vi.fn();
|
||||
const existing = file('current.png', 'abc', 'image/png');
|
||||
const valid = file('new.png', 'abcd', 'image/png');
|
||||
const wrongType = file('note.txt', 'abcd', 'text/plain');
|
||||
const tooLarge = file('large.png', 'abcdef', 'image/png');
|
||||
const duplicate = file('current.png', 'abc', 'image/png');
|
||||
const blocked = file('blocked.png', 'abcd', 'image/png');
|
||||
opts.files.current = [existing];
|
||||
opts.accept.current = 'image/*';
|
||||
opts.maxSize.current = 4;
|
||||
opts.validate.current = (candidate) =>
|
||||
candidate.name === 'blocked.png'
|
||||
? { reasons: ['FILE_CUSTOM_ERROR'], message: 'blocked by policy' }
|
||||
: null;
|
||||
opts.onFilesChange.current = onFilesChange;
|
||||
opts.onReject.current = onReject;
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => FileUploadProvider.create(opts));
|
||||
|
||||
provider.addFiles([valid, wrongType, tooLarge, duplicate, blocked]);
|
||||
|
||||
expect(opts.files.current).toEqual([existing, valid]);
|
||||
expect(onFilesChange).toHaveBeenCalledWith([existing, valid]);
|
||||
expect(onReject).toHaveBeenCalledOnce();
|
||||
expect(onReject.mock.calls[0][0]).toEqual([
|
||||
{ file: wrongType, reasons: ['FILE_INVALID_TYPE'], message: undefined },
|
||||
{ file: tooLarge, reasons: ['FILE_TOO_LARGE'], message: undefined },
|
||||
{ file: duplicate, reasons: ['FILE_EXISTS'], message: undefined },
|
||||
{ file: blocked, reasons: ['FILE_CUSTOM_ERROR'], message: 'blocked by policy' }
|
||||
]);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('caps accepted files by maxFiles and exposes root state attrs', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createFileUploadOpts();
|
||||
const onReject = vi.fn();
|
||||
opts.maxFiles.current = 2;
|
||||
opts.required.current = true;
|
||||
opts.files.current = [file('a.png', 'a', 'image/png')];
|
||||
opts.onReject.current = onReject;
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => FileUploadProvider.create(opts));
|
||||
|
||||
provider.addFiles([file('b.png', 'b', 'image/png'), file('c.png', 'c', 'image/png')]);
|
||||
|
||||
expect(opts.files.current.map((entry) => entry.name)).toEqual(['a.png', 'b.png']);
|
||||
expect(onReject.mock.calls[0][0][0].reasons).toEqual(['TOO_MANY_FILES']);
|
||||
expect(provider.props).toMatchObject({
|
||||
'data-required': '',
|
||||
'data-empty': undefined
|
||||
});
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('wires hidden input, label and dropzone interactions through the root provider', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createFileUploadOpts();
|
||||
opts.accept.current = '.png';
|
||||
opts.name.current = 'attachments';
|
||||
const listen = vi.spyOn(dom, 'listen');
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => FileUploadProvider.create(opts));
|
||||
vi.spyOn(FileUploadProvider.ctx, 'get').mockReturnValue(provider);
|
||||
|
||||
const hiddenInput = FileUploadHiddenInputProvider.create({
|
||||
id: state('file-upload-input'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
const label = FileUploadLabelProvider.create({
|
||||
id: state('file-upload-label'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
const dropzone = FileUploadDropzoneProvider.create({
|
||||
id: state('file-upload-dropzone'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
const dropped = file('drop.png', 'a', 'image/png');
|
||||
const preventDefault = vi.fn();
|
||||
|
||||
await tick();
|
||||
|
||||
expect(listen).toHaveBeenCalledWith(document, 'dragover', expect.any(Function));
|
||||
expect(listen).toHaveBeenCalledWith(document, 'drop', expect.any(Function));
|
||||
expect(provider.hiddenInputId.current).toBe('file-upload-input');
|
||||
expect(label.props.for).toBe('file-upload-input');
|
||||
expect(hiddenInput.props).toMatchObject({
|
||||
type: 'file',
|
||||
accept: '.png',
|
||||
multiple: true,
|
||||
name: 'attachments',
|
||||
'aria-hidden': true
|
||||
});
|
||||
expect(dropzone.props).toMatchObject({
|
||||
role: 'button',
|
||||
tabindex: 0,
|
||||
'aria-label': '#?components.file-upload.dropzone|Upload files',
|
||||
'aria-disabled': 'false'
|
||||
});
|
||||
|
||||
dropzone.ondragenter({ preventDefault } as never);
|
||||
expect(dropzone.dragging).toBe(true);
|
||||
|
||||
dropzone.ondrop({
|
||||
preventDefault,
|
||||
dataTransfer: { files: [dropped] }
|
||||
} as never);
|
||||
|
||||
expect(opts.files.current).toEqual([dropped]);
|
||||
expect(dropzone.dragging).toBe(false);
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('exposes item metadata, removal, progress clamping and clear trigger state', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createFileUploadOpts();
|
||||
const first = file('first.txt', 'hello', 'text/plain');
|
||||
const second = file('second.txt', 'world', 'text/plain');
|
||||
opts.files.current = [first, second];
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => FileUploadProvider.create(opts));
|
||||
vi.spyOn(FileUploadProvider.ctx, 'get').mockReturnValue(provider);
|
||||
|
||||
const item = FileUploadItemProvider.create({
|
||||
id: state('file-upload-item'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
file: state(first),
|
||||
index: state(0)
|
||||
});
|
||||
vi.spyOn(FileUploadItemProvider.ctx, 'get').mockReturnValue(item);
|
||||
|
||||
const name = FileUploadItemNameProvider.create({
|
||||
id: state('file-upload-item-name'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
const size = FileUploadItemSizeProvider.create({
|
||||
id: state('file-upload-item-size'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
const progress = FileUploadItemProgressProvider.create({
|
||||
id: state('file-upload-item-progress'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
value: state(140)
|
||||
});
|
||||
const remove = FileUploadItemRemoveProvider.create({
|
||||
id: state('file-upload-item-remove'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
const clear = FileUploadClearTriggerProvider.create({
|
||||
id: state('file-upload-clear'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
|
||||
expect(item.snippetProps).toEqual({ file: first, index: 0 });
|
||||
expect(item.props).toMatchObject({
|
||||
role: 'listitem',
|
||||
'data-file-name': 'first.txt',
|
||||
'data-file-type': 'text/plain'
|
||||
});
|
||||
expect(name.defaultContent).toBe('first.txt');
|
||||
expect(size.defaultContent).toBe('5 B');
|
||||
expect(progress.props).toMatchObject({
|
||||
role: 'progressbar',
|
||||
'aria-valuenow': 100,
|
||||
'aria-label': 'first.txt: 100%',
|
||||
'data-value': 100
|
||||
});
|
||||
|
||||
remove.onclick({} as never);
|
||||
expect(opts.files.current).toEqual([second]);
|
||||
|
||||
clear.onclick();
|
||||
expect(opts.files.current).toEqual([]);
|
||||
expect(clear.isDisabled).toBe(true);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue