Add field provider coverage

active-uix
dev 5 months ago
parent 6a5152a5d7
commit acab242c61

@ -99,9 +99,11 @@ Actualizacion 2026-05-15:
`uix.timers`, labels traducidos/override y errores `onError`.
- Cobertura nueva para Tooltip: delay/close con `uix.timers`, skip-delay
de grupo, focus/blur, disabled y partes trigger/content/arrow.
- Cobertura nueva para Field: wiring de partes/ARIA, input guardado por
disabled/readonly e integración con parent Form.
- Validado por fase:
- `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 210 archivos, 2178 tests OK.
- `npm run test` -> 211 archivos, 2181 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.
@ -140,6 +142,7 @@ Actualizacion 2026-05-15:
- `collapsible-provider.svelte.test.ts` -> 3 tests OK.
- `clipboard-provider.svelte.test.ts` -> 2 tests OK.
- `tooltip-provider.svelte.test.ts` -> 3 tests OK.
- `field-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.

@ -531,6 +531,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
reset con `uix.timers`, labels traducidos/override y errores `onError`.
- `tooltip/tooltip-provider.svelte.test.ts` — delay/close con `uix.timers`,
skip-delay de grupo, focus/blur, disabled y partes trigger/content/arrow.
- `field/field-provider.svelte.test.ts` — wiring de partes/ARIA, input
guardado por disabled/readonly e integración con parent Form.
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,243 @@
// @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 { FormProvider } from '../form/form-provider.svelte';
import {
FieldControlProvider,
FieldErrorTextProvider,
FieldHelperTextProvider,
FieldInputProvider,
FieldLabelProvider,
FieldPrefixProvider,
FieldProvider,
FieldSuffixProvider
} from './field-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(FieldProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(FormProvider, 'get').mockReturnValue(undefined);
return { dom };
}
function fieldOpts(root = document.createElement('div')) {
return {
id: state('field-root'),
ref: state<HTMLElement | null>(root),
name: state<string | undefined>('email'),
label: state<string | undefined>('Email'),
disabled: state(false),
readonly: state(false),
required: state(false),
invalid: state(false),
dirty: state<boolean | undefined>(undefined),
touched: state<boolean | undefined>(undefined),
state: state<'idle' | 'error' | 'disabled' | 'readonly' | 'loading'>('idle')
};
}
function inputOpts(inputEl = document.createElement('input')) {
return {
id: state('field-input'),
ref: state<HTMLElement | null>(inputEl),
value: state(''),
type: state<'text'>('text'),
name: state<string | undefined>('email'),
onValueChange: state<(value: string) => void>(() => undefined)
};
}
describe('FieldProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('wires field parts, ARIA props and runtime attrs', async () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const labelEl = document.createElement('label');
const controlEl = document.createElement('div');
const inputEl = document.createElement('input');
const helperEl = document.createElement('div');
const errorEl = document.createElement('div');
document.body.append(root, labelEl, controlEl, inputEl, helperEl, errorEl);
const opts = fieldOpts(root);
opts.required.current = true;
opts.invalid.current = true;
const { result, cleanup } = withEffectRoot(() => {
const provider = FieldProvider.create(opts);
vi.spyOn(FieldProvider, 'require').mockReturnValue(provider);
const label = FieldLabelProvider.create({
id: state('field-label'),
ref: state<HTMLElement | null>(labelEl)
});
const control = FieldControlProvider.create({
id: state('field-control'),
ref: state<HTMLElement | null>(controlEl)
});
const helper = FieldHelperTextProvider.create({
id: state('field-helper'),
ref: state<HTMLElement | null>(helperEl)
});
const error = FieldErrorTextProvider.create({
id: state('field-error'),
ref: state<HTMLElement | null>(errorEl)
});
const input = FieldInputProvider.create(inputOpts(inputEl));
const prefix = FieldPrefixProvider.create({
id: state('field-prefix'),
ref: state<HTMLElement | null>(document.createElement('span'))
});
const suffix = FieldSuffixProvider.create({
id: state('field-suffix'),
ref: state<HTMLElement | null>(document.createElement('span'))
});
return { provider, label, control, input, helper, error, prefix, suffix };
});
expect(result.provider.snippetProps).toMatchObject({
state: 'error',
isInvalid: true,
isRequired: true,
isDirty: false,
isTouched: false
});
expect(result.input.props).toMatchObject({
id: 'field-input',
'data-field-input': '',
type: 'text',
name: 'email',
required: true,
'aria-labelledby': 'field-label',
'aria-invalid': 'true',
'aria-describedby': 'field-helper field-error',
'aria-required': 'true',
'data-state': 'error'
});
expect(result.helper.props).toMatchObject({
id: 'field-helper',
'data-field-helper-text': ''
});
expect(result.error.props).toMatchObject({
id: 'field-error',
'data-field-error-text': ''
});
expect(result.prefix.props).toMatchObject({ 'aria-hidden': 'true' });
expect(result.suffix.props).toMatchObject({ 'aria-hidden': 'true' });
await tick();
expect(root.getAttribute('data-state')).toBe('error');
expect(root.getAttribute('data-required')).toBe('');
expect(root.getAttribute('data-invalid')).toBe('');
expect(root.getAttribute('data-field-name')).toBe('email');
expect(labelEl.getAttribute('for')).toBe('field-input');
expect(controlEl.getAttribute('data-state')).toBe('error');
expect(errorEl.getAttribute('role')).toBe('alert');
expect(errorEl.getAttribute('aria-live')).toBe('polite');
cleanup();
dom.dispose();
});
it('updates input value unless disabled or readonly', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const inputEl = document.createElement('input');
document.body.append(root, inputEl);
const opts = fieldOpts(root);
const inputOptions = inputOpts(inputEl);
const onValueChange = vi.fn();
inputOptions.onValueChange.current = onValueChange;
const { result, cleanup } = withEffectRoot(() => {
const provider = FieldProvider.create(opts);
vi.spyOn(FieldProvider, 'require').mockReturnValue(provider);
return FieldInputProvider.create(inputOptions);
});
inputEl.value = 'first';
result.oninput({ currentTarget: inputEl } as never);
expect(inputOptions.value.current).toBe('first');
expect(onValueChange).toHaveBeenCalledWith('first');
opts.readonly.current = true;
inputEl.value = 'second';
result.oninput({ currentTarget: inputEl } as never);
expect(inputOptions.value.current).toBe('first');
opts.readonly.current = false;
opts.disabled.current = true;
inputEl.value = 'third';
result.oninput({ currentTarget: inputEl } as never);
expect(inputOptions.value.current).toBe('first');
cleanup();
dom.dispose();
});
it('integrates with parent Form state and touched registry', async () => {
const { dom } = installSomaHarness();
const formRuntime = {
registerField: vi.fn(),
unregisterField: vi.fn(),
getFieldState: vi.fn(() => ({
isInvalid: true,
dirty: true,
touched: false,
errors: ['Required']
})),
setFieldTouched: vi.fn()
};
vi.spyOn(FormProvider, 'get').mockReturnValue({
form: formRuntime,
isPending: false
} as unknown as FormProvider);
const opts = fieldOpts();
const { result: provider, cleanup } = withEffectRoot(() => FieldProvider.create(opts));
await tick();
expect(formRuntime.registerField).toHaveBeenCalledWith('email', { label: 'Email' });
expect(provider.isInvalid).toBe(true);
expect(provider.isDirty).toBe(true);
expect(provider.isTouched).toBe(false);
expect(provider.fieldErrors).toEqual(['Required']);
provider.onfocusout();
expect(formRuntime.setFieldTouched).toHaveBeenCalledWith('email', true);
cleanup();
expect(formRuntime.unregisterField).toHaveBeenCalledWith('email');
dom.dispose();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.