From 2255d9f706afec955be17f556a8438cdd2687ced Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 16 May 2026 03:36:54 +0200 Subject: [PATCH] Add drag drop provider coverage --- continue.md | 6 +- src/uix/soma/SOMA_ARCHITECTURE.md | 10 +- .../drag-drop-provider.svelte.test.ts | 304 ++++++++++++++++++ 3 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 src/uix/soma/components/drag-drop/drag-drop-provider.svelte.test.ts diff --git a/continue.md b/continue.md index 01d351e3b..38c31d36a 100644 --- a/continue.md +++ b/continue.md @@ -143,9 +143,11 @@ Actualizacion 2026-05-15: attrs runtime y partes Submit/Reset/ErrorSummary sobre `$libs/forms`. - Cobertura nueva para ToastProvider: viewport hotkey via `ActiveDom`, runtime por item, ARIA/parts, auto-dismiss y close action. + - Cobertura nueva para DragDrop: ruta de teclado start/nav/drop, filtros + `accept`, prevención de drag, cancel y preview. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 232 archivos, 2240 tests OK. + - `npm run test` -> 233 archivos, 2242 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. @@ -206,6 +208,8 @@ Actualizacion 2026-05-15: - `table-provider.svelte.test.ts` -> 2 tests OK. - `form-provider.svelte.test.ts` -> 2 tests OK. - `toast-provider.svelte.test.ts` -> 2 tests OK. + - `drag-drop-provider.svelte.test.ts` -> 2 tests OK. + - guardia `*-provider.svelte.ts` sin test directo -> `NO_MISSING_PROVIDER_TESTS`. - 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 231789b76..25b940fe8 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -575,11 +575,13 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A `ActiveDom`, attrs runtime y partes Submit/Reset/ErrorSummary. - `toast/toast-provider.svelte.test.ts` — viewport hotkey via `ActiveDom`, runtime por item, ARIA/parts, auto-dismiss y close action. +- `drag-drop/drag-drop-provider.svelte.test.ts` — ruta de teclado + start/nav/drop, filtros `accept`, prevención de drag, cancel y preview. -Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por -componentes de riesgo medio y familias menos centrales. `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`. +La guardia `*-provider.svelte.ts` sin test directo devuelve +`NO_MISSING_PROVIDER_TESTS`: todos los providers Soma activos tienen cobertura +directa. `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/drag-drop/drag-drop-provider.svelte.test.ts b/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.test.ts new file mode 100644 index 000000000..3d1d856e9 --- /dev/null +++ b/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.test.ts @@ -0,0 +1,304 @@ +// @vitest-environment jsdom + +import { tick } from 'svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +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 { + DragDropProvider, + DraggableProvider, + DragPreviewProvider, + DroppableProvider +} from './drag-drop-provider.svelte'; +import type { DragData } 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 timers = { + schedule: vi.fn((_key: string, _delay: number, callback: () => void) => ({ + cancel: vi.fn(), + reschedule: vi.fn(), + callback + })) + }; + const soma = { + dom, + langs: { + ts: vi.fn((key: string) => key), + t: vi.fn((key: string, vars: Record) => + Object.entries(vars).reduce( + (text, [name, value]) => text.replace(`{{${name}}}`, value), + key + ) + ) + }, + uix: { timers }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(DragDropProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom, timers }; +} + +function createRoot() { + const root = document.createElement('div'); + root.setAttribute('data-drag-drop', ''); + document.body.appendChild(root); + return root; +} + +function createDropZone(value: string, text: string) { + const el = document.createElement('div'); + el.setAttribute('data-drag-drop-droppable', ''); + el.setAttribute('data-value', value); + el.setAttribute('data-text-value', text); + el.textContent = text; + return el; +} + +function providerOpts(root = createRoot()) { + return { + id: state('drag-drop-root'), + ref: state(root), + onDragStart: state< + ((e: { value: string; data: DragData; preventDefault: () => void }) => void) | undefined + >(undefined), + onDragEnd: state(vi.fn()), + onDrop: state(vi.fn()), + announceEnabled: state(true) + }; +} + +function draggableOpts(el: HTMLElement, value = 'card-a') { + return { + id: state(`drag-${value}`), + ref: state(el), + value: state(value), + data: state({ kind: 'card' }), + disabled: state(false), + moveBuffer: state(5), + textValue: state('Card A') + }; +} + +function droppableOpts( + id: string, + el: HTMLElement, + accept: ((data: DragData, value: string) => boolean) | undefined = undefined +) { + return { + id: state(id), + ref: state(el), + accept: state(accept), + disabled: state(false), + textValue: state(el.getAttribute('data-text-value') ?? undefined) + }; +} + +function keyboardEvent(key: string) { + return new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true + }); +} + +describe('DragDropProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('starts keyboard dragging, navigates accepting targets and commits drop', async () => { + const { dom } = installSomaHarness(); + const focusSpy = vi.spyOn(dom, 'focus'); + const root = createRoot(); + const sourceEl = document.createElement('div'); + const firstTarget = createDropZone('todo', 'Todo'); + const secondTarget = createDropZone('done', 'Done'); + root.append(sourceEl, firstTarget, secondTarget); + const opts = providerOpts(root); + + const { result, cleanup } = withEffectRoot(() => { + const provider = DragDropProvider.create(opts); + vi.spyOn(DragDropProvider, 'require').mockReturnValue(provider); + const draggable = DraggableProvider.create(draggableOpts(sourceEl)); + const first = DroppableProvider.create(droppableOpts('drop-todo', firstTarget)); + const second = DroppableProvider.create(droppableOpts('drop-done', secondTarget)); + const preview = DragPreviewProvider.create({ + id: state('drag-preview'), + ref: state(document.createElement('div')) + }); + return { provider, draggable, first, second, preview }; + }); + + await tick(); + const space = keyboardEvent(' '); + result.draggable.onkeydown(space); + expect(space.defaultPrevented).toBe(true); + expect(result.provider.active?.value).toBe('card-a'); + expect(result.provider.keyboard).toBe(true); + expect(result.provider.overTarget).toBe(firstTarget); + expect(focusSpy).toHaveBeenLastCalledWith(firstTarget); + expect(result.provider.props['data-dragging']).toBe(''); + expect(result.draggable.props).toMatchObject({ + role: 'button', + tabindex: 0, + 'aria-grabbed': true, + 'data-dragging': '', + 'data-value': 'card-a' + }); + expect(result.first.props).toMatchObject({ + role: 'region', + tabindex: 0, + 'aria-dropeffect': 'move', + 'data-dragover': '', + 'data-accepting': '' + }); + expect(result.preview.props).toMatchObject({ + 'aria-hidden': true, + 'data-active': '' + }); + + window.dispatchEvent(keyboardEvent('ArrowDown')); + expect(result.provider.overTarget).toBe(secondTarget); + expect(focusSpy).toHaveBeenLastCalledWith(secondTarget); + + window.dispatchEvent(keyboardEvent('Enter')); + expect(opts.onDrop.current).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'card-a', + data: { kind: 'card' }, + target: secondTarget, + targetLabel: 'Done' + }) + ); + expect(opts.onDragEnd.current).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'card-a', + outcome: 'drop', + target: secondTarget + }) + ); + expect(result.provider.active).toBeNull(); + expect(result.provider.keyboard).toBe(false); + expect(result.provider.props['data-dragging']).toBeUndefined(); + + cleanup(); + dom.dispose(); + }); + + it('honors drag prevention, accept filters, cancel outcome and pointer preview style', async () => { + const { dom } = installSomaHarness(); + const root = createRoot(); + const sourceEl = document.createElement('div'); + sourceEl.getBoundingClientRect = () => + ({ left: 10, top: 20, width: 100, height: 30 }) as DOMRect; + const blockedTarget = createDropZone('blocked', 'Blocked'); + const allowedTarget = createDropZone('allowed', 'Allowed'); + root.append(sourceEl, blockedTarget, allowedTarget); + const opts = providerOpts(root); + opts.onDragStart.current = (event) => event.preventDefault(); + + const { result, cleanup } = withEffectRoot(() => { + const provider = DragDropProvider.create(opts); + vi.spyOn(DragDropProvider, 'require').mockReturnValue(provider); + const blocked = DroppableProvider.create( + droppableOpts('drop-blocked', blockedTarget, () => false) + ); + const allowed = DroppableProvider.create( + droppableOpts('drop-allowed', allowedTarget, () => true) + ); + const preview = DragPreviewProvider.create({ + id: state('drag-preview'), + ref: state(document.createElement('div')) + }); + return { provider, blocked, allowed, preview }; + }); + + await tick(); + expect( + result.provider.startDrag({ + value: 'card-a', + data: { kind: 'card' }, + label: 'Card A', + source: sourceEl, + pointer: { x: 20, y: 30 }, + keyboard: false + }) + ).toBe(false); + expect(result.provider.active).toBeNull(); + + opts.onDragStart.current = undefined; + expect( + result.provider.startDrag({ + value: 'card-a', + data: { kind: 'card' }, + label: 'Card A', + source: sourceEl, + pointer: { x: 20, y: 30 }, + keyboard: false + }) + ).toBe(true); + expect(result.blocked.isAccepting).toBe(false); + expect(result.allowed.isAccepting).toBe(true); + expect(result.preview.props).toMatchObject({ + 'data-active': '', + style: expect.objectContaining({ + position: 'fixed', + left: '32px', + top: '42px', + 'pointer-events': 'none' + }) + }); + + result.provider.commitDrop(blockedTarget); + expect(opts.onDragEnd.current).toHaveBeenLastCalledWith( + expect.objectContaining({ + value: 'card-a', + outcome: 'cancel' + }) + ); + expect(result.provider.active).toBeNull(); + + result.provider.startDrag({ + value: 'card-a', + data: { kind: 'card' }, + label: 'Card A', + source: sourceEl, + pointer: null, + keyboard: true + }); + result.provider.cancelDrag(); + expect(opts.onDragEnd.current).toHaveBeenLastCalledWith( + expect.objectContaining({ + value: 'card-a', + outcome: 'cancel' + }) + ); + expect(result.preview.props.style).toEqual({ display: 'none' }); + + cleanup(); + dom.dispose(); + }); +});