diff --git a/continue.md b/continue.md index 2899eb31a..a9d228e3b 100644 --- a/continue.md +++ b/continue.md @@ -103,9 +103,11 @@ Actualizacion 2026-05-15: disabled/readonly e integración con parent Form. - Cobertura nueva para SearchField: props root/input/clear, input/submit/ clear, foco y OR-merge con Field. + - Cobertura nueva para Pagination: rangos/ellipsis, clamp reactivo, slice, + triggers prev/next, item selected y disabled global. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 212 archivos, 2184 tests OK. + - `npm run test` -> 213 archivos, 2187 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. @@ -146,6 +148,7 @@ Actualizacion 2026-05-15: - `tooltip-provider.svelte.test.ts` -> 3 tests OK. - `field-provider.svelte.test.ts` -> 3 tests OK. - `search-field-provider.svelte.test.ts` -> 3 tests OK. + - `pagination-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 dcb9a9035..b4d55778b 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -535,6 +535,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A guardado por disabled/readonly e integración con parent Form. - `search-field/search-field-provider.svelte.test.ts` — props root/input/clear, input/submit/clear, foco y OR-merge con Field. +- `pagination/pagination-provider.svelte.test.ts` — rangos/ellipsis, clamp + reactivo, slice, triggers prev/next, item selected y disabled global. Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por componentes de riesgo medio y familias menos centrales. `table-core`, diff --git a/src/uix/soma/components/pagination/pagination-provider.svelte.test.ts b/src/uix/soma/components/pagination/pagination-provider.svelte.test.ts new file mode 100644 index 000000000..25ed625d1 --- /dev/null +++ b/src/uix/soma/components/pagination/pagination-provider.svelte.test.ts @@ -0,0 +1,222 @@ +// @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 { + PaginationEllipsisProvider, + PaginationItemProvider, + PaginationNextTriggerProvider, + PaginationPrevTriggerProvider, + PaginationProvider +} from './pagination-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, + langs: { + t: vi.fn((_key: string, vars?: Record) => `Page ${vars?.value}`) + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(PaginationProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function paginationOpts(root = document.createElement('nav')) { + return { + id: state('pagination-root'), + ref: state(root), + page: state(5), + count: state(95), + pageSize: state(10), + siblingCount: state(1), + boundaryCount: state(1), + disabled: state(false) + }; +} + +describe('PaginationProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('computes page ranges, clamps current page and slices data', async () => { + const { dom } = installSomaHarness(); + const opts = paginationOpts(); + + const { result: provider, cleanup } = withEffectRoot(() => PaginationProvider.create(opts)); + + expect(provider.totalPages).toBe(10); + expect(provider.pages).toEqual([ + { type: 'page', value: 1 }, + { type: 'ellipsis', position: 'start' }, + { type: 'page', value: 4 }, + { type: 'page', value: 5 }, + { type: 'page', value: 6 }, + { type: 'ellipsis', position: 'end' }, + { type: 'page', value: 10 } + ]); + expect(provider.pageRange).toEqual({ start: 41, end: 50 }); + expect(provider.slice(Array.from({ length: 95 }, (_, i) => i + 1))).toEqual([ + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50 + ]); + + provider.goToNextPage(); + expect(opts.page.current).toBe(6); + provider.goToPrevPage(); + expect(opts.page.current).toBe(5); + provider.goToFirstPage(); + expect(opts.page.current).toBe(1); + provider.goToLastPage(); + expect(opts.page.current).toBe(10); + + opts.page.current = 99; + await tick(); + expect(opts.page.current).toBe(10); + + cleanup(); + dom.dispose(); + }); + + it('projects trigger, item and ellipsis attrs through the runtime', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('nav'); + const prevEl = document.createElement('button'); + const nextEl = document.createElement('button'); + const itemEl = document.createElement('button'); + const ellipsisEl = document.createElement('span'); + document.body.append(root, prevEl, nextEl, itemEl, ellipsisEl); + const opts = paginationOpts(root); + opts.page.current = 3; + + const { result, cleanup } = withEffectRoot(() => { + const provider = PaginationProvider.create(opts); + vi.spyOn(PaginationProvider, 'require').mockReturnValue(provider); + const prev = PaginationPrevTriggerProvider.create({ + id: state('pagination-prev'), + ref: state(prevEl), + disabled: state(false) + }); + const next = PaginationNextTriggerProvider.create({ + id: state('pagination-next'), + ref: state(nextEl), + disabled: state(false) + }); + const item = PaginationItemProvider.create({ + id: state('pagination-page-3'), + ref: state(itemEl), + value: state(3) + }); + const ellipsis = PaginationEllipsisProvider.create({ + id: state('pagination-ellipsis'), + ref: state(ellipsisEl), + position: state<'end'>('end') + }); + return { provider, prev, next, item, ellipsis }; + }); + + expect(result.provider.props).toMatchObject({ + id: 'pagination-root', + 'data-pagination': '' + }); + expect(result.prev.props.disabled).toBeUndefined(); + expect(result.next.props.disabled).toBeUndefined(); + expect(result.item.props).toMatchObject({ + id: 'pagination-page-3', + 'data-pagination-item': '', + disabled: undefined + }); + expect(result.ellipsis.props).toMatchObject({ + id: 'pagination-ellipsis', + 'data-pagination-ellipsis': '' + }); + + await tick(); + expect(root.getAttribute('role')).toBe('navigation'); + expect(prevEl.getAttribute('type')).toBe('button'); + expect(prevEl.getAttribute('aria-label')).toContain('Previous page'); + expect(nextEl.getAttribute('aria-label')).toContain('Next page'); + expect(itemEl.getAttribute('data-selected')).toBe(''); + expect(itemEl.getAttribute('data-value')).toBe('3'); + expect(itemEl.getAttribute('aria-current')).toBe('page'); + expect(itemEl.getAttribute('aria-label')).toBe('Page 3'); + expect(ellipsisEl.getAttribute('data-position')).toBe('end'); + expect(ellipsisEl.getAttribute('aria-hidden')).toBe('true'); + + result.prev.onclick({ currentTarget: prevEl } as never); + expect(opts.page.current).toBe(2); + result.next.onclick({ currentTarget: nextEl } as never); + expect(opts.page.current).toBe(3); + result.item.onclick({ currentTarget: itemEl } as never); + expect(opts.page.current).toBe(3); + + cleanup(); + dom.dispose(); + }); + + it('disables navigation and item selection when provider is disabled', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('nav'); + const nextEl = document.createElement('button'); + const itemEl = document.createElement('button'); + document.body.append(root, nextEl, itemEl); + const opts = paginationOpts(root); + opts.page.current = 1; + opts.disabled.current = true; + + const { result, cleanup } = withEffectRoot(() => { + const provider = PaginationProvider.create(opts); + vi.spyOn(PaginationProvider, 'require').mockReturnValue(provider); + const next = PaginationNextTriggerProvider.create({ + id: state('pagination-next'), + ref: state(nextEl), + disabled: state(false) + }); + const item = PaginationItemProvider.create({ + id: state('pagination-page-2'), + ref: state(itemEl), + value: state(2) + }); + return { next, item }; + }); + + expect(result.next.props.disabled).toBe(true); + expect(result.item.props.disabled).toBe(true); + result.next.onclick({ currentTarget: nextEl } as never); + result.item.onclick({ currentTarget: itemEl } as never); + expect(opts.page.current).toBe(1); + + await tick(); + expect(root.getAttribute('data-disabled')).toBe(''); + expect(nextEl.getAttribute('data-disabled')).toBe(''); + expect(itemEl.getAttribute('data-disabled')).toBe(''); + + cleanup(); + dom.dispose(); + }); +});