Add radio group provider coverage

active-uix
dev 5 months ago
parent 37cd177c37
commit 0d338d47f2

@ -78,9 +78,11 @@ Actualizacion 2026-05-15:
por item, navegación de triggers y props de header/content.
- Cobertura nueva para Checkbox: commits check/uncheck, estado indeterminate,
hidden input y sincronización con CheckboxGroup.
- Cobertura nueva para RadioGroup: roving tabindex, selección síncrona,
navegación con auto-select, hidden input y guardas readonly/disabled.
- Validado por fase:
- `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 199 archivos, 2141 tests OK.
- `npm run test` -> 200 archivos, 2145 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.
@ -108,6 +110,7 @@ Actualizacion 2026-05-15:
- `tree-view-provider.svelte.test.ts` -> 4 tests OK.
- `accordion-provider.svelte.test.ts` -> 4 tests OK.
- `checkbox-provider.svelte.test.ts` -> 4 tests OK.
- `radio-group-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.

@ -510,13 +510,15 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
eventos `open/close` por item, navegación de triggers y header/content.
- `checkbox/checkbox-provider.svelte.test.ts` — commits check/uncheck,
indeterminate, hidden input y sincronización con `CheckboxGroup`.
- `radio-group/radio-group-provider.svelte.test.ts` — roving tabindex,
selección síncrona, navegación con auto-select, hidden input y guardas.
Pendiente: seguir ampliando cobertura por familias de alto riesgo fuera del
bloque fecha/hora, virtualización, inputs numericos, file upload, color y
navegacion superior/treegrid/tree-view/dropdown/context-menu/menubar/listbox/
accordion/checkbox. `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`.
accordion/checkbox/radio-group. `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

@ -0,0 +1,262 @@
// @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 {
RadioGroupHiddenInputProvider,
RadioGroupIndicatorProvider,
RadioGroupLabelProvider,
RadioGroupProvider,
RadioItemProvider
} from './radio-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() {
const dom = createActiveDom();
const soma = {
dom,
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(RadioGroupProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(RadioItemProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function createRoot() {
const root = document.createElement('div');
root.setAttribute('data-radio-group', '');
document.body.appendChild(root);
return root;
}
function createItemEl(value: string, disabled = false) {
const item = document.createElement('button');
item.setAttribute('data-radio-group-item', '');
item.setAttribute('data-value', value);
if (disabled) item.setAttribute('data-disabled', '');
return item;
}
function radioGroupOpts(root = createRoot()) {
return {
id: state('radio-group-root'),
ref: state<HTMLElement | null>(root),
value: state(''),
disabled: state(false),
required: state(false),
readonly: state(false),
invalid: state(false),
orientation: state<'horizontal' | 'vertical'>('vertical'),
loop: state(true),
dir: state<'ltr' | 'rtl'>('ltr'),
name: state('')
};
}
function itemOpts(id: string, value: string, el: HTMLElement, disabled = false) {
return {
id: state(id),
ref: state<HTMLElement | null>(el),
value: state(value),
disabled: state(disabled)
};
}
function partOpts(id: string, el: HTMLElement = document.createElement('span')) {
return {
id: state(id),
ref: state<HTMLElement | null>(el)
};
}
function applyAttrs(el: HTMLElement, props: Record<string, unknown>) {
for (const [key, value] of Object.entries(props)) {
if (
!key.startsWith('data-') &&
!key.startsWith('aria-') &&
key !== 'role' &&
key !== 'id' &&
key !== 'disabled' &&
key !== 'type'
) {
continue;
}
if (value === undefined || value === false || typeof value === 'function') continue;
el.setAttribute(key, value === true ? '' : String(value));
}
}
function keyEvent(key: string, currentTarget: HTMLElement) {
return {
key,
currentTarget,
preventDefault: vi.fn()
};
}
describe('RadioGroupProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('exposes root props and label context', () => {
const { dom } = installSomaHarness();
const opts = radioGroupOpts();
opts.required.current = true;
const { result, cleanup } = withEffectRoot(() => {
const provider = RadioGroupProvider.create(opts);
vi.spyOn(RadioGroupProvider.ctx, 'get').mockReturnValue(provider);
vi.spyOn(RadioGroupProvider, 'require').mockReturnValue(provider);
const label = RadioGroupLabelProvider.create(partOpts('radio-group-label'));
return { provider, label };
});
expect(result.provider.props).toMatchObject({
id: 'radio-group-root',
dir: 'ltr'
});
expect(result.label.props.id).toBe('radio-group-label');
cleanup();
dom.dispose();
});
it('selects items synchronously and exposes indicator/hidden input state', () => {
const { dom } = installSomaHarness();
const root = createRoot();
const alphaEl = createItemEl('alpha');
const betaEl = createItemEl('beta');
root.append(alphaEl, betaEl);
const opts = radioGroupOpts(root);
opts.name.current = 'flavor';
opts.required.current = true;
const { result, cleanup } = withEffectRoot(() => {
const provider = RadioGroupProvider.create(opts);
vi.spyOn(RadioGroupProvider.ctx, 'get').mockReturnValue(provider);
const alpha = RadioItemProvider.create(itemOpts('radio-alpha', 'alpha', alphaEl));
const beta = RadioItemProvider.create(itemOpts('radio-beta', 'beta', betaEl));
vi.spyOn(RadioItemProvider, 'require').mockReturnValue(beta);
const indicator = RadioGroupIndicatorProvider.create(partOpts('radio-beta-indicator'));
const input = RadioGroupHiddenInputProvider.create(partOpts('radio-beta-input'));
return { alpha, beta, indicator, input };
});
expect(result.alpha.props.tabindex).toBe(0);
expect(result.beta.props.tabindex).toBe(-1);
result.beta.onclick({ currentTarget: betaEl } as never);
expect(opts.value.current).toBe('beta');
expect(result.alpha.props.tabindex).toBe(-1);
expect(result.beta.props.tabindex).toBe(0);
expect(result.indicator.isVisible).toBe(true);
expect(result.input.props).toMatchObject({
type: 'radio',
tabindex: -1,
name: 'flavor',
value: 'beta',
checked: true,
required: true
});
cleanup();
dom.dispose();
});
it('navigates enabled items and auto-selects the focused target', () => {
const { dom } = installSomaHarness();
const focusSpy = vi.spyOn(dom, 'focus');
const root = createRoot();
const alphaEl = createItemEl('alpha');
const disabledEl = createItemEl('disabled', true);
const betaEl = createItemEl('beta');
root.append(alphaEl, disabledEl, betaEl);
const opts = radioGroupOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = RadioGroupProvider.create(opts);
vi.spyOn(RadioGroupProvider.ctx, 'get').mockReturnValue(provider);
const alpha = RadioItemProvider.create(itemOpts('radio-alpha', 'alpha', alphaEl));
const disabled = RadioItemProvider.create(
itemOpts('radio-disabled', 'disabled', disabledEl, true)
);
applyAttrs(disabledEl, disabled.props);
const beta = RadioItemProvider.create(itemOpts('radio-beta', 'beta', betaEl));
return { alpha, beta };
});
const next = keyEvent('ArrowDown', alphaEl);
result.alpha.onkeydown(next as never);
expect(next.preventDefault).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenCalledWith(betaEl);
expect(opts.value.current).toBe('beta');
expect(result.beta.props.tabindex).toBe(0);
const prev = keyEvent('ArrowUp', betaEl);
result.beta.onkeydown(prev as never);
expect(prev.preventDefault).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenLastCalledWith(alphaEl);
expect(opts.value.current).toBe('alpha');
cleanup();
dom.dispose();
});
it('respects readonly, group disabled and item disabled guards', () => {
const { dom } = installSomaHarness();
const root = createRoot();
const alphaEl = createItemEl('alpha');
const betaEl = createItemEl('beta');
root.append(alphaEl, betaEl);
const opts = radioGroupOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = RadioGroupProvider.create(opts);
vi.spyOn(RadioGroupProvider.ctx, 'get').mockReturnValue(provider);
const alpha = RadioItemProvider.create(itemOpts('radio-alpha', 'alpha', alphaEl));
const beta = RadioItemProvider.create(itemOpts('radio-beta', 'beta', betaEl, true));
return { provider, alpha, beta };
});
opts.readonly.current = true;
result.alpha.onclick({ currentTarget: alphaEl } as never);
expect(opts.value.current).toBe('');
opts.readonly.current = false;
opts.disabled.current = true;
result.alpha.onclick({ currentTarget: alphaEl } as never);
expect(opts.value.current).toBe('');
expect(result.alpha.props.disabled).toBe(true);
opts.disabled.current = false;
result.beta.onclick({ currentTarget: betaEl } as never);
expect(opts.value.current).toBe('');
expect(result.beta.props.disabled).toBe(true);
cleanup();
dom.dispose();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.