diff --git a/continue.md b/continue.md index aaa5a2f3b..41fd4c60b 100644 --- a/continue.md +++ b/continue.md @@ -53,9 +53,11 @@ Actualizacion 2026-05-15: triggers, props ARIA y scrubber. - Correccion NumberField: el root expone `dir` efectivo (`prop -> prefs`) en lugar del prop crudo. + - Cobertura nueva para FileUpload: aceptación/rechazo de ficheros, dropzone, + input oculto, items, progress, remove y clear. - Validado por fase: - `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 Dialog/Drawer/Popover/Select -> 10 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-grid-provider.svelte.test.ts` -> 2 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'` -> solo queda `select/components/select-provider.svelte`, que es wrapper publico, no runtime provider. diff --git a/src/uix/soma/SOMA_ARCHITECTURE.md b/src/uix/soma/SOMA_ARCHITECTURE.md index 50f3d61db..772e7b377 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -484,11 +484,13 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A y `scrollToCell` via `ActiveDom`. - `number-field/number-field-provider.svelte.test.ts` — parsing localizable, 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 -bloque fecha/hora, virtualización e inputs numericos. `table-core`, -`form-core` y el scorer de Command ya no viven dentro de Soma: se consumen -desde `$libs/datagrid`, `$libs/forms` y `$libs/strings`. +bloque fecha/hora, virtualización, inputs numericos y file upload. +`table-core`, `form-core` y el scorer de Command ya no viven dentro de Soma: +se consumen desde `$libs/datagrid`, `$libs/forms` y `$libs/strings`. ### Convencion diff --git a/src/uix/soma/components/file-upload/file-upload-provider.svelte.test.ts b/src/uix/soma/components/file-upload/file-upload-provider.svelte.test.ts new file mode 100644 index 000000000..47fa4528a --- /dev/null +++ b/src/uix/soma/components/file-upload/file-upload-provider.svelte.test.ts @@ -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(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) => + 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(null), + files: state([]), + accept: state(undefined), + maxFiles: state(undefined), + maxSize: state(undefined), + minSize: state(undefined), + multiple: state(true), + disabled: state(false), + required: state(false), + name: state(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(null) + }); + const label = FileUploadLabelProvider.create({ + id: state('file-upload-label'), + ref: state(null) + }); + const dropzone = FileUploadDropzoneProvider.create({ + id: state('file-upload-dropzone'), + ref: state(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(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(null) + }); + const size = FileUploadItemSizeProvider.create({ + id: state('file-upload-item-size'), + ref: state(null) + }); + const progress = FileUploadItemProgressProvider.create({ + id: state('file-upload-item-progress'), + ref: state(null), + value: state(140) + }); + const remove = FileUploadItemRemoveProvider.create({ + id: state('file-upload-item-remove'), + ref: state(null) + }); + const clear = FileUploadClearTriggerProvider.create({ + id: state('file-upload-clear'), + ref: state(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(); + }); +});