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