diff --git a/continue.md b/continue.md index 96b22e1e1..874afdaa0 100644 --- a/continue.md +++ b/continue.md @@ -42,9 +42,14 @@ Actualizacion 2026-05-15: date/time y commit solo cuando los segmentos renderizados estan completos. - Correccion DateField: el announcer se inicializa con `$effect` en vez de `onMount()` dentro de la clase. + - Cobertura nueva para VirtualList: cálculo de ventana, `scrollToIndex` y + compensación anti-jump. + - Correccion VirtualList: la compensación de mediciones dinámicas sobre + viewports internos pasa por `ActiveDom.scrollTo(...)` y usa + `scrollOffset + delta` como fuente reactiva. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 184 archivos, 2080 tests OK. + - `npm run test` -> 185 archivos, 2083 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. @@ -57,6 +62,7 @@ Actualizacion 2026-05-15: - `time-field-provider.svelte.test.ts` -> 3 tests OK. - `date-range-field-provider.svelte.test.ts` -> 3 tests OK. - `date-field-provider.svelte.test.ts` -> 7 tests OK. + - `virtual-list-provider.svelte.test.ts` -> 3 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 17ec28009..b992f50cd 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -478,11 +478,13 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A completos. - `date-range-field/date-range-field-provider.svelte.test.ts` — validación de orden/min/max, validación custom y foco de label via `ActiveDom`. +- `virtual-list/virtual-list-provider.svelte.test.ts` — cálculo de ventana, + `scrollToIndex` y compensación anti-jump via `ActiveDom`. Pendiente: seguir ampliando cobertura por familias de alto riesgo fuera del -bloque fecha/hora. `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. `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/virtual-list/virtual-list-provider.svelte.test.ts b/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.test.ts new file mode 100644 index 000000000..9cc531b62 --- /dev/null +++ b/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment jsdom + +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 type { Orientation } from '../../types'; + +import { VirtualListProvider } from './virtual-list-provider.svelte'; + +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, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(VirtualListProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function createVirtualListOpts() { + return { + id: state('virtual-list-root'), + ref: state(null), + count: state(10), + itemSize: state(20), + estimateSize: state<(index: number) => number>(() => 20), + overscan: state(1), + orientation: state('vertical'), + getItemKey: state<(index: number) => string | number>((index) => `row-${index}`) + }; +} + +describe('VirtualListProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('computes fixed-size virtual items from viewport and overscan', () => { + const { dom } = installSomaHarness(); + const opts = createVirtualListOpts(); + + const { result: provider, cleanup } = withEffectRoot(() => VirtualListProvider.create(opts)); + provider.viewportSize = 100; + provider.scrollOffset = 50; + + expect(provider.totalSize).toBe(200); + expect(provider.virtualItems.map((item) => item.index)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(provider.virtualItems[0]).toEqual({ + index: 1, + start: 20, + size: 20, + key: 'row-1' + }); + + cleanup(); + dom.dispose(); + }); + + it('routes scrollToIndex through ActiveDom for internal viewports', () => { + const { dom } = installSomaHarness(); + const opts = createVirtualListOpts(); + const viewport = document.createElement('div'); + const scrollTo = vi.spyOn(dom, 'scrollTo'); + + const { result: provider, cleanup } = withEffectRoot(() => VirtualListProvider.create(opts)); + provider.viewportRef = viewport; + provider.viewportSize = 100; + + provider.scrollToIndex(5, { align: 'center' }); + + expect(scrollTo).toHaveBeenCalledWith(viewport, { + top: 60, + behavior: 'auto' + }); + + cleanup(); + dom.dispose(); + }); + + it('compensates dynamic measurements above the viewport through ActiveDom', () => { + const { dom } = installSomaHarness(); + const opts = createVirtualListOpts(); + opts.itemSize.current = undefined; + const viewport = document.createElement('div'); + viewport.scrollTop = 50; + const scrollTo = vi.spyOn(dom, 'scrollTo'); + + const { result: provider, cleanup } = withEffectRoot(() => VirtualListProvider.create(opts)); + provider.viewportRef = viewport; + provider.viewportSize = 100; + provider.scrollOffset = 50; + + provider.measureItem(0, 30); + + expect(scrollTo).toHaveBeenCalledWith(viewport, { top: 60 }); + + cleanup(); + dom.dispose(); + }); +}); diff --git a/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts b/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts index 15e791465..a21712140 100644 --- a/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts +++ b/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts @@ -218,8 +218,10 @@ export class VirtualListProvider { const vp = this.viewportRef; if (vp) { - if (this.isHorizontal) vp.scrollLeft += delta; - else vp.scrollTop += delta; + this.soma.dom.scrollTo( + vp, + this.isHorizontal ? { left: this.scrollOffset + delta } : { top: this.scrollOffset + delta } + ); return; } // Window-scroll mode: shift document scroll by the delta.