From c108d02512d543c5773c741dc7f54b2242253741 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 16 May 2026 02:20:27 +0200 Subject: [PATCH] Add rating group provider coverage --- continue.md | 5 +- src/uix/soma/SOMA_ARCHITECTURE.md | 2 + .../rating-group-provider.svelte.test.ts | 274 ++++++++++++++++++ 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 src/uix/soma/components/rating-group/rating-group-provider.svelte.test.ts diff --git a/continue.md b/continue.md index 6f142d46b..306102e3f 100644 --- a/continue.md +++ b/continue.md @@ -117,9 +117,11 @@ Actualizacion 2026-05-15: collapse/expand y resize por pointer/teclado con attrs ARIA/data. - Cobertura nueva para PinInput: props del input oculto, merge con Field, patrón/paste, completion y estado de celdas/caret. + - Cobertura nueva para RatingGroup: slider attrs, merge con Field, + half hover/click, clearable y teclado LTR/RTL. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 219 archivos, 2206 tests OK. + - `npm run test` -> 220 archivos, 2209 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. @@ -167,6 +169,7 @@ Actualizacion 2026-05-15: - `scroll-area-provider.svelte.test.ts` -> 3 tests OK. - `splitter-provider.svelte.test.ts` -> 3 tests OK. - `pin-input-provider.svelte.test.ts` -> 3 tests OK. + - `rating-group-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 f3b8c4e71..5fc7c70e9 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -549,6 +549,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A collapse/expand y resize por pointer/teclado con attrs ARIA/data. - `pin-input/pin-input-provider.svelte.test.ts` — props del input oculto, merge con Field, patrón/paste, completion y estado de celdas/caret. +- `rating-group/rating-group-provider.svelte.test.ts` — slider attrs, + merge con Field, half hover/click, clearable y teclado LTR/RTL. 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/rating-group/rating-group-provider.svelte.test.ts b/src/uix/soma/components/rating-group/rating-group-provider.svelte.test.ts new file mode 100644 index 000000000..80295682d --- /dev/null +++ b/src/uix/soma/components/rating-group/rating-group-provider.svelte.test.ts @@ -0,0 +1,274 @@ +// @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 { FieldProvider } from '$soma/components/field/field-provider.svelte'; + +import { RatingGroupItemProvider, RatingGroupProvider } from './rating-group-provider.svelte'; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +function installSomaHarness(field: FieldProvider | undefined = undefined) { + const dom = createActiveDom(); + const soma = { + dom, + langs: { + ts: vi.fn(() => 'Rating') + }, + 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(field); + vi.spyOn(RatingGroupProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function ratingOpts(root = document.createElement('div')) { + return { + id: state('rating-root'), + ref: state(root), + value: state(2.5), + min: state(0), + max: state(5), + allowHalf: state(true), + hoverPreview: state(true), + clearable: state(true), + disabled: state(false), + readonly: state(false), + required: state(false), + invalid: state(false), + orientation: state<'horizontal' | 'vertical'>('horizontal'), + dir: state<'ltr' | 'rtl'>('ltr'), + name: state('rating'), + ariaLabel: state(undefined), + onValueChange: state<((value: number) => void) | undefined>(undefined) + }; +} + +function itemOpts(index: number, el: HTMLElement) { + return { + id: state(`rating-item-${index}`), + ref: state(el), + index: state(index), + disabled: state(false) + }; +} + +function pointerEvent( + el: HTMLElement, + overrides: Partial & { + clientX?: number; + clientY?: number; + pointerType?: string; + } = {} +) { + return { + currentTarget: el, + clientX: 0, + clientY: 0, + pointerType: 'mouse', + ...overrides + }; +} + +function keyEvent(key: string) { + return { + key, + preventDefault: vi.fn() + }; +} + +function fakeField() { + return { + get isDisabled() { + return false; + }, + get isReadonly() { + return false; + }, + get isRequired() { + return true; + }, + get isInvalid() { + return true; + } + } as FieldProvider; +} + +describe('RatingGroupProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('projects slider attrs, Field flags and item states', async () => { + const { dom } = installSomaHarness(fakeField()); + const root = document.createElement('div'); + const activeEl = document.createElement('span'); + const partialEl = document.createElement('span'); + document.body.append(root, activeEl, partialEl); + const opts = ratingOpts(root); + + const { result, cleanup } = withEffectRoot(() => { + const provider = RatingGroupProvider.create(opts); + vi.spyOn(RatingGroupProvider, 'require').mockReturnValue(provider); + const active = RatingGroupItemProvider.create(itemOpts(1, activeEl)); + const partial = RatingGroupItemProvider.create(itemOpts(2, partialEl)); + return { provider, active, partial }; + }); + + expect(result.provider.items).toEqual([ + { index: 0, state: 'active' }, + { index: 1, state: 'active' }, + { index: 2, state: 'partial' }, + { index: 3, state: 'inactive' }, + { index: 4, state: 'inactive' } + ]); + expect(result.provider.snippetProps).toMatchObject({ + value: 2.5, + min: 0, + max: 5, + isFocused: false, + isHovering: false + }); + expect(result.provider.props.tabindex).toBe(0); + expect(result.active.snippetProps).toEqual({ index: 1, state: 'active' }); + expect(result.partial.snippetProps).toEqual({ index: 2, state: 'partial' }); + + result.provider.onfocus(); + await tick(); + expect(root.getAttribute('role')).toBe('slider'); + expect(root.getAttribute('aria-label')).toBe('Rating'); + expect(root.getAttribute('aria-valuenow')).toBe('2.5'); + expect(root.getAttribute('aria-valuemin')).toBe('0'); + expect(root.getAttribute('aria-valuemax')).toBe('5'); + expect(root.getAttribute('data-required')).toBe(''); + expect(root.getAttribute('data-invalid')).toBe(''); + expect(root.getAttribute('data-focused')).toBe(''); + expect(activeEl.getAttribute('role')).toBe('presentation'); + expect(activeEl.getAttribute('data-state')).toBe('active'); + expect(activeEl.getAttribute('data-value')).toBe('2'); + expect(partialEl.getAttribute('data-state')).toBe('partial'); + expect(partialEl.getAttribute('data-value')).toBe('3'); + + cleanup(); + dom.dispose(); + }); + + it('previews half values, commits clicks and clears the current value', () => { + const { dom } = installSomaHarness(); + const focusSpy = vi.spyOn(dom, 'focus'); + const root = document.createElement('div'); + const itemEl = document.createElement('span'); + itemEl.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 20 }) as DOMRect; + document.body.append(root, itemEl); + const opts = ratingOpts(root); + const onValueChange = vi.fn(); + opts.onValueChange.current = onValueChange; + + const { result, cleanup } = withEffectRoot(() => { + const provider = RatingGroupProvider.create(opts); + vi.spyOn(RatingGroupProvider, 'require').mockReturnValue(provider); + const item = RatingGroupItemProvider.create(itemOpts(2, itemEl)); + return { provider, item }; + }); + + result.item.onpointermove(pointerEvent(itemEl, { clientX: 25 }) as never); + expect(result.provider.hoverValue).toBe(2.5); + expect(result.provider.items[2].state).toBe('partial'); + + result.item.onpointermove(pointerEvent(itemEl, { clientX: 75, pointerType: 'touch' }) as never); + expect(result.provider.hoverValue).toBe(2.5); + + result.provider.onpointerleave(); + expect(result.provider.hoverValue).toBeNull(); + + result.item.onclick(pointerEvent(itemEl, { clientX: 75 }) as never); + expect(opts.value.current).toBe(3); + expect(onValueChange).toHaveBeenLastCalledWith(3); + expect(focusSpy).toHaveBeenCalledWith(root); + + result.item.onclick(pointerEvent(itemEl, { clientX: 75 }) as never); + expect(opts.value.current).toBe(0); + expect(onValueChange).toHaveBeenLastCalledWith(0); + + cleanup(); + dom.dispose(); + }); + + it('resizes with keyboard and guards disabled or readonly state', () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + document.body.appendChild(root); + const opts = ratingOpts(root); + opts.value.current = 2; + const onValueChange = vi.fn(); + opts.onValueChange.current = onValueChange; + + const { result, cleanup } = withEffectRoot(() => RatingGroupProvider.create(opts)); + + const next = keyEvent('ArrowRight'); + result.onkeydown(next as never); + expect(next.preventDefault).toHaveBeenCalledOnce(); + expect(opts.value.current).toBe(2.5); + + const prev = keyEvent('ArrowLeft'); + result.onkeydown(prev as never); + expect(prev.preventDefault).toHaveBeenCalledOnce(); + expect(opts.value.current).toBe(2); + + const home = keyEvent('Home'); + result.onkeydown(home as never); + expect(home.preventDefault).toHaveBeenCalledOnce(); + expect(opts.value.current).toBe(0); + + const end = keyEvent('End'); + result.onkeydown(end as never); + expect(end.preventDefault).toHaveBeenCalledOnce(); + expect(opts.value.current).toBe(5); + + const numeric = keyEvent('4'); + result.onkeydown(numeric as never); + expect(numeric.preventDefault).toHaveBeenCalledOnce(); + expect(opts.value.current).toBe(4); + + opts.dir.current = 'rtl'; + const rtlLeft = keyEvent('ArrowLeft'); + result.onkeydown(rtlLeft as never); + expect(rtlLeft.preventDefault).toHaveBeenCalledOnce(); + expect(opts.value.current).toBe(4.5); + + opts.readonly.current = true; + result.onkeydown(keyEvent('ArrowLeft') as never); + expect(opts.value.current).toBe(4.5); + + opts.readonly.current = false; + opts.disabled.current = true; + expect(result.props.tabindex).toBe(-1); + result.onkeydown(keyEvent('End') as never); + expect(opts.value.current).toBe(4.5); + expect(onValueChange).toHaveBeenLastCalledWith(4.5); + + cleanup(); + dom.dispose(); + }); +});