Add file upload provider coverage

active-uix
dev 5 months ago
parent eeb168043b
commit cab1363616

@ -53,9 +53,11 @@ Actualizacion 2026-05-15:
triggers, props ARIA y scrubber. triggers, props ARIA y scrubber.
- Correccion NumberField: el root expone `dir` efectivo (`prop -> prefs`) en - Correccion NumberField: el root expone `dir` efectivo (`prop -> prefs`) en
lugar del prop crudo. lugar del prop crudo.
- Cobertura nueva para FileUpload: aceptación/rechazo de ficheros, dropzone,
input oculto, items, progress, remove y clear.
- Validado por fase: - Validado por fase:
- `npm run check` -> 0 errores, 0 warnings. - `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 187 archivos, 2089 tests OK. - `npm run test` -> 188 archivos, 2093 tests OK.
- tests focales de Select/Popover/Toast/RangeCalendar -> 11 tests OK. - tests focales de Select/Popover/Toast/RangeCalendar -> 11 tests OK.
- tests focales Dialog/Drawer/Popover/Select -> 10 tests OK. - tests focales Dialog/Drawer/Popover/Select -> 10 tests OK.
- `command-provider.svelte.test.ts` -> 2 tests OK. - `command-provider.svelte.test.ts` -> 2 tests OK.
@ -71,6 +73,7 @@ Actualizacion 2026-05-15:
- `virtual-list-provider.svelte.test.ts` -> 3 tests OK. - `virtual-list-provider.svelte.test.ts` -> 3 tests OK.
- `virtual-grid-provider.svelte.test.ts` -> 2 tests OK. - `virtual-grid-provider.svelte.test.ts` -> 2 tests OK.
- `number-field-provider.svelte.test.ts` -> 4 tests OK. - `number-field-provider.svelte.test.ts` -> 4 tests OK.
- `file-upload-provider.svelte.test.ts` -> 4 tests OK.
- guardia `rg "from './.*provider\\.svelte'" src/uix/soma/components -g 'exports.ts'` - guardia `rg "from './.*provider\\.svelte'" src/uix/soma/components -g 'exports.ts'`
-> solo queda `select/components/select-provider.svelte`, que es wrapper -> solo queda `select/components/select-provider.svelte`, que es wrapper
publico, no runtime provider. publico, no runtime provider.

@ -484,11 +484,13 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
y `scrollToCell` via `ActiveDom`. y `scrollToCell` via `ActiveDom`.
- `number-field/number-field-provider.svelte.test.ts` — parsing localizable, - `number-field/number-field-provider.svelte.test.ts` — parsing localizable,
teclado spinbutton, triggers, props ARIA y scrubber. teclado spinbutton, triggers, props ARIA y scrubber.
- `file-upload/file-upload-provider.svelte.test.ts` — aceptación/rechazo,
dropzone, input oculto, items, progress y acciones remove/clear.
Pendiente: seguir ampliando cobertura por familias de alto riesgo fuera del Pendiente: seguir ampliando cobertura por familias de alto riesgo fuera del
bloque fecha/hora, virtualización e inputs numericos. `table-core`, bloque fecha/hora, virtualización, inputs numericos y file upload.
`form-core` y el scorer de Command ya no viven dentro de Soma: se consumen `table-core`, `form-core` y el scorer de Command ya no viven dentro de Soma:
desde `$libs/datagrid`, `$libs/forms` y `$libs/strings`. se consumen desde `$libs/datagrid`, `$libs/forms` y `$libs/strings`.
### Convencion ### Convencion

@ -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…
Cancel
Save

Powered by TurnKey Linux.