Add rating group provider coverage

active-uix
dev 5 months ago
parent 419ec80890
commit c108d02512

@ -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.

@ -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`,

@ -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<T>(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<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
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<HTMLElement | null>(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<string | undefined>('rating'),
ariaLabel: state<string | undefined>(undefined),
onValueChange: state<((value: number) => void) | undefined>(undefined)
};
}
function itemOpts(index: number, el: HTMLElement) {
return {
id: state(`rating-item-${index}`),
ref: state<HTMLElement | null>(el),
index: state(index),
disabled: state(false)
};
}
function pointerEvent(
el: HTMLElement,
overrides: Partial<PointerEvent> & {
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();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.