Add pin input provider coverage

active-uix
dev 5 months ago
parent 4b5d9078ed
commit 419ec80890

@ -115,9 +115,11 @@ Actualizacion 2026-05-15:
visibilidad/timers de scrollbar, track click, thumb y corner.
- Cobertura nueva para Splitter: registro de paneles, clamp,
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.
- Validado por fase:
- `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 218 archivos, 2203 tests OK.
- `npm run test` -> 219 archivos, 2206 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.
@ -164,6 +166,7 @@ Actualizacion 2026-05-15:
- `stepper-provider.svelte.test.ts` -> 3 tests OK.
- `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.
- 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.

@ -547,6 +547,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
viewport, visibilidad/timers de scrollbar, track click, thumb y corner.
- `splitter/splitter-provider.svelte.test.ts` — registro de paneles, clamp,
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.
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,286 @@
// @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 {
PinInputCellProvider,
PinInputInputProvider,
PinInputProvider
} from './pin-input-provider.svelte';
import type { PinInputCellData } from './types';
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(() => 'Verification code')
},
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(PinInputProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function pinInputOpts(root = document.createElement('div')) {
return {
id: state('pin-input-root'),
ref: state<HTMLElement | null>(root),
inputId: state('pin-input-hidden'),
value: state('12'),
length: state(4),
type: state<'numeric' | 'alphanumeric' | 'alphabetic' | 'custom'>('numeric'),
pattern: state<string | RegExp | undefined>(undefined),
pasteTransformer: state<((text: string) => string) | undefined>(undefined),
mask: state(false),
placeholder: state(''),
disabled: state(false),
readonly: state(false),
required: state(false),
invalid: state(false),
name: state<string | undefined>('code'),
autocomplete: state<string | undefined>(undefined),
inputmode: state<'text' | 'numeric' | 'tel' | 'decimal' | undefined>(undefined),
ariaLabel: state<string | undefined>(undefined),
onComplete: state<((value: string) => void) | undefined>(undefined)
};
}
function inputEvent(input: HTMLInputElement) {
return {
currentTarget: input,
preventDefault: vi.fn()
};
}
function pasteEvent(input: HTMLInputElement, text: string) {
return {
currentTarget: input,
preventDefault: vi.fn(),
clipboardData: {
getData: vi.fn(() => text)
}
};
}
function fakeField() {
return {
inputId: state(''),
get isDisabled() {
return false;
},
get isReadonly() {
return false;
},
get isRequired() {
return true;
},
get isInvalid() {
return true;
}
} as FieldProvider;
}
describe('PinInputProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('resolves input props, OR-merges Field flags and fires onComplete once', async () => {
const field = fakeField();
const { dom } = installSomaHarness(field);
const root = document.createElement('div');
const inputEl = document.createElement('input');
document.body.append(root, inputEl);
const opts = pinInputOpts(root);
const onComplete = vi.fn();
opts.onComplete.current = onComplete;
const { result, cleanup } = withEffectRoot(() => {
const provider = PinInputProvider.create(opts);
vi.spyOn(PinInputProvider, 'require').mockReturnValue(provider);
const input = PinInputInputProvider.create({
id: state('pin-input-hidden'),
ref: state<HTMLInputElement | null>(inputEl)
});
provider.setInputRef(inputEl);
return { provider, input };
});
expect(field.inputId.current).toBe('pin-input-hidden');
expect(result.provider.props).toMatchObject({
id: 'pin-input-root',
'data-pin-input': '',
'data-required': '',
'data-invalid': ''
});
expect(result.input.props).toMatchObject({
id: 'pin-input-hidden',
type: 'text',
inputmode: 'numeric',
autocomplete: 'one-time-code',
maxlength: 4,
value: '12',
name: 'code',
required: true,
'aria-label': 'Verification code',
'aria-invalid': true
});
inputEl.value = '1234';
result.input.props.oninput(inputEvent(inputEl) as never);
await Promise.resolve();
await tick();
expect(opts.value.current).toBe('1234');
expect(result.provider.isComplete).toBe(true);
expect(onComplete).toHaveBeenCalledOnce();
expect(onComplete).toHaveBeenCalledWith('1234');
inputEl.value = '1234';
result.input.props.oninput(inputEvent(inputEl) as never);
await tick();
expect(onComplete).toHaveBeenCalledOnce();
cleanup();
dom.dispose();
});
it('filters input by pattern and distributes transformed paste text', async () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const inputEl = document.createElement('input');
document.body.append(root, inputEl);
const opts = pinInputOpts(root);
opts.pasteTransformer.current = (text) => text.replace(/\D/g, '');
const { result, cleanup } = withEffectRoot(() => {
const provider = PinInputProvider.create(opts);
vi.spyOn(PinInputProvider, 'require').mockReturnValue(provider);
const input = PinInputInputProvider.create({
id: state('pin-input-hidden'),
ref: state<HTMLInputElement | null>(inputEl)
});
provider.setInputRef(inputEl);
return { provider, input };
});
inputEl.value = '12a';
result.input.props.oninput(inputEvent(inputEl) as never);
expect(opts.value.current).toBe('12');
expect(inputEl.value).toBe('12');
inputEl.selectionStart = 1;
inputEl.selectionEnd = 2;
const paste = pasteEvent(inputEl, '9-8');
result.input.props.onpaste(paste as never);
expect(paste.preventDefault).toHaveBeenCalledOnce();
inputEl.value = opts.value.current;
await Promise.resolve();
expect(opts.value.current).toBe('198');
expect(inputEl.selectionStart).toBe(3);
expect(inputEl.selectionEnd).toBe(3);
inputEl.selectionStart = 0;
inputEl.selectionEnd = 0;
result.input.props.onpaste(pasteEvent(inputEl, 'abc') as never);
expect(opts.value.current).toBe('198');
opts.disabled.current = true;
inputEl.value = '1234';
const disabledInput = inputEvent(inputEl);
result.input.props.oninput(disabledInput as never);
expect(disabledInput.preventDefault).toHaveBeenCalledOnce();
expect(opts.value.current).toBe('198');
cleanup();
dom.dispose();
});
it('tracks focus selection and exposes cell state props', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const inputEl = document.createElement('input');
const filledCellEl = document.createElement('span');
const activeCellEl = document.createElement('span');
document.body.append(root, inputEl, filledCellEl, activeCellEl);
const opts = pinInputOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = PinInputProvider.create(opts);
vi.spyOn(PinInputProvider, 'require').mockReturnValue(provider);
provider.setInputRef(inputEl);
inputEl.value = opts.value.current;
provider.onfocus({ currentTarget: inputEl } as never);
const filled = PinInputCellProvider.create({
id: state('pin-input-cell-0'),
ref: state<HTMLElement | null>(filledCellEl),
index: state(0),
cell: state<PinInputCellData>(provider.cells[0])
});
const active = PinInputCellProvider.create({
id: state('pin-input-cell-2'),
ref: state<HTMLElement | null>(activeCellEl),
index: state(2),
cell: state<PinInputCellData>(provider.cells[2])
});
return { provider, filled, active };
});
expect(result.provider.focused).toBe(true);
expect(result.provider.cells).toEqual([
{ char: '1', isActive: false, isFilled: true, hasFakeCaret: false },
{ char: '2', isActive: false, isFilled: true, hasFakeCaret: false },
{ char: null, isActive: true, isFilled: false, hasFakeCaret: true },
{ char: null, isActive: false, isFilled: false, hasFakeCaret: false }
]);
expect(result.provider.snippetProps).toMatchObject({
isFocused: true,
value: '12'
});
expect(result.filled.props).toMatchObject({
id: 'pin-input-cell-0',
role: 'presentation',
'aria-hidden': true,
'data-filled': ''
});
expect(result.active.props).toMatchObject({
id: 'pin-input-cell-2',
'data-active': ''
});
expect(result.active.props['data-placeholder']).toBeUndefined();
result.provider.onblur({ currentTarget: inputEl } as never);
expect(result.provider.focused).toBe(false);
expect(result.provider.selectionStart).toBeNull();
expect(result.provider.props['data-focused']).toBeUndefined();
cleanup();
dom.dispose();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.