From 41da8a2b26a5e295b234c314ab7b5237d954233d Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 16 May 2026 01:05:18 +0200 Subject: [PATCH] Add slider provider coverage --- continue.md | 5 +- src/uix/soma/SOMA_ARCHITECTURE.md | 2 + .../slider/slider-provider.svelte.test.ts | 288 ++++++++++++++++++ 3 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 src/uix/soma/components/slider/slider-provider.svelte.test.ts diff --git a/continue.md b/continue.md index 296f34a09..1e8eb50fa 100644 --- a/continue.md +++ b/continue.md @@ -89,9 +89,11 @@ Actualizacion 2026-05-15: - Cobertura nueva para Tabs: registro de trigger/content, activacion automatic/manual, navegacion roving, fallback tab stop y partes list/content/indicator. + - Cobertura nueva para Slider: contrato provider/range/thumb/tick, + snapping/clamping, drag pointer confirmado y teclado de thumb. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 204 archivos, 2162 tests OK. + - `npm run test` -> 205 archivos, 2166 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. @@ -124,6 +126,7 @@ Actualizacion 2026-05-15: - `toggle-provider.svelte.test.ts` -> 4 tests OK. - `toggle-group-provider.svelte.test.ts` -> 4 tests OK. - `tabs-provider.svelte.test.ts` -> 4 tests OK. + - `slider-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 b6f476f78..93936a69a 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -520,6 +520,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A roving tabindex y navegación de items saltando disabled. - `tabs/tabs-provider.svelte.test.ts` — registros trigger/content, activacion automatic/manual, navegacion roving, fallback tab stop y partes auxiliares. +- `slider/slider-provider.svelte.test.ts` — contrato provider/range/thumb/tick, + snapping/clamping, drag pointer confirmado y teclado de thumb. 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/slider/slider-provider.svelte.test.ts b/src/uix/soma/components/slider/slider-provider.svelte.test.ts new file mode 100644 index 000000000..4c83dea34 --- /dev/null +++ b/src/uix/soma/components/slider/slider-provider.svelte.test.ts @@ -0,0 +1,288 @@ +// @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 { + SliderProvider, + SliderRangeProvider, + SliderThumbProvider, + SliderTickProvider +} from './slider-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(SliderProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function createSliderRoot() { + const root = document.createElement('div'); + root.getBoundingClientRect = vi.fn( + () => + ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 100, + bottom: 20, + width: 100, + height: 20, + toJSON: () => ({}) + }) as DOMRect + ); + root.setPointerCapture = vi.fn(); + root.releasePointerCapture = vi.fn(); + document.body.appendChild(root); + return root; +} + +function sliderOpts(root = createSliderRoot()) { + return { + id: state('slider-root'), + ref: state(root), + value: state([25, 75]), + type: state<'single' | 'multiple'>('multiple'), + min: state(0), + max: state(100), + step: state(5), + orientation: state<'horizontal' | 'vertical'>('horizontal'), + disabled: state(false), + dir: state<'ltr' | 'rtl'>('ltr'), + name: state('price'), + thumbLabels: state(['Minimum', 'Maximum']), + onValueCommit: state<(value: number[]) => void>(() => undefined) + }; +} + +function pointerEvent(clientX: number, clientY: number, pointerId = 1): PointerEvent { + return { clientX, clientY, pointerId } as unknown as PointerEvent; +} + +function keyEvent(key: string): KeyboardEvent { + return { + key, + preventDefault: vi.fn() + } as unknown as KeyboardEvent; +} + +describe('SliderProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('exposes provider, range, thumb and tick contracts', async () => { + const { dom } = installSomaHarness(); + const root = createSliderRoot(); + const rangeEl = document.createElement('span'); + const thumbEl = document.createElement('span'); + const tickEl = document.createElement('span'); + const opts = sliderOpts(root); + + const { result, cleanup } = withEffectRoot(() => { + const provider = SliderProvider.create(opts); + vi.spyOn(SliderProvider, 'require').mockReturnValue(provider); + const range = SliderRangeProvider.create({ + id: state('slider-range'), + ref: state(rangeEl) + }); + const thumb = SliderThumbProvider.create({ + id: state('slider-thumb-0'), + ref: state(thumbEl), + index: state(0) + }); + const tick = SliderTickProvider.create({ + id: state('slider-tick-50'), + ref: state(tickEl), + value: state(50) + }); + return { provider, range, thumb, tick }; + }); + + expect(result.provider.props).toMatchObject({ + id: 'slider-root', + 'data-slider': '', + dir: 'ltr' + }); + expect(result.provider.props.style).toMatchObject({ + position: 'relative', + 'touch-action': 'pan-y' + }); + expect(result.range.props).toMatchObject({ + id: 'slider-range', + 'data-slider-range': '' + }); + expect(result.range.props.style).toMatchObject({ + position: 'absolute', + left: '25%', + width: '50%' + }); + expect(result.thumb.props).toMatchObject({ + id: 'slider-thumb-0', + 'data-slider-thumb': '', + role: 'slider', + tabindex: 0, + 'aria-valuemin': 0, + 'aria-valuemax': 100, + 'aria-valuenow': 25, + 'aria-label': 'Minimum', + 'aria-orientation': 'horizontal' + }); + expect(result.thumb.props.style).toMatchObject({ + position: 'absolute', + left: '25%', + transform: 'translate(-50%, -50%)' + }); + expect(result.thumb.inputProps).toMatchObject({ + type: 'hidden', + name: 'price[0]', + value: 25, + disabled: undefined + }); + expect(result.tick.props.style).toMatchObject({ + position: 'absolute', + left: '50%', + transform: 'translateX(-50%)' + }); + + await tick(); + expect(root.getAttribute('role')).toBe('group'); + expect(root.getAttribute('data-orientation')).toBe('horizontal'); + expect(tickEl.getAttribute('data-value')).toBe('50'); + expect(tickEl.getAttribute('data-active')).toBe(''); + + cleanup(); + dom.dispose(); + }); + + it('snaps, clamps and prevents crossover for multiple thumbs', () => { + const { dom } = installSomaHarness(); + const opts = sliderOpts(); + + const { result: provider, cleanup } = withEffectRoot(() => SliderProvider.create(opts)); + + provider.updateValue(0, 33); + expect(opts.value.current).toEqual([35, 75]); + + provider.updateValue(0, 120); + expect(opts.value.current).toEqual([75, 75]); + + opts.value.current = [25, 75]; + provider.updateValue(1, 10); + expect(opts.value.current).toEqual([25, 25]); + + opts.disabled.current = true; + provider.updateValue(0, 50); + expect(opts.value.current).toEqual([25, 25]); + + cleanup(); + dom.dispose(); + }); + + it('updates and commits value during confirmed pointer drag', () => { + const { dom } = installSomaHarness(); + const root = createSliderRoot(); + const opts = sliderOpts(root); + opts.value.current = [0]; + opts.type.current = 'single'; + const onValueCommit = vi.fn(); + opts.onValueCommit.current = onValueCommit; + + const { result: provider, cleanup } = withEffectRoot(() => SliderProvider.create(opts)); + + provider.onpointerdown(pointerEvent(30, 10)); + expect(provider.activeThumbIndex).toBe(0); + + provider.onpointermove(pointerEvent(32, 10)); + expect(opts.value.current).toEqual([0]); + expect(root.setPointerCapture).not.toHaveBeenCalled(); + + provider.onpointermove(pointerEvent(50, 10)); + expect(root.setPointerCapture).toHaveBeenCalledWith(1); + expect(opts.value.current).toEqual([50]); + + provider.onpointerup(pointerEvent(50, 10)); + expect(root.releasePointerCapture).toHaveBeenCalledWith(1); + expect(provider.activeThumbIndex).toBeNull(); + expect(onValueCommit).toHaveBeenCalledWith([50]); + + cleanup(); + dom.dispose(); + }); + + it('commits thumb keyboard changes and respects disabled state', () => { + const { dom } = installSomaHarness(); + const opts = sliderOpts(); + opts.value.current = [50]; + opts.type.current = 'single'; + const onValueCommit = vi.fn(); + opts.onValueCommit.current = onValueCommit; + + const { result: thumb, cleanup } = withEffectRoot(() => { + const provider = SliderProvider.create(opts); + vi.spyOn(SliderProvider, 'require').mockReturnValue(provider); + return SliderThumbProvider.create({ + id: state('slider-thumb'), + ref: state(document.createElement('span')), + index: state(0) + }); + }); + + const right = keyEvent('ArrowRight'); + thumb.onkeydown(right); + expect(right.preventDefault).toHaveBeenCalledOnce(); + expect(opts.value.current).toEqual([55]); + expect(onValueCommit).toHaveBeenLastCalledWith([55]); + + const pageDown = keyEvent('PageDown'); + thumb.onkeydown(pageDown); + expect(opts.value.current).toEqual([5]); + + const home = keyEvent('Home'); + thumb.onkeydown(home); + expect(opts.value.current).toEqual([0]); + + const end = keyEvent('End'); + thumb.onkeydown(end); + expect(opts.value.current).toEqual([100]); + + opts.disabled.current = true; + const left = keyEvent('ArrowLeft'); + thumb.onkeydown(left); + expect(left.preventDefault).not.toHaveBeenCalled(); + expect(opts.value.current).toEqual([100]); + expect(thumb.props.tabindex).toBeUndefined(); + expect(thumb.inputProps.disabled).toBe(true); + + cleanup(); + dom.dispose(); + }); +});