From 419ec80890f58b27d06aa4c6373b767c307d747e Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 16 May 2026 02:15:58 +0200 Subject: [PATCH] Add pin input provider coverage --- continue.md | 5 +- src/uix/soma/SOMA_ARCHITECTURE.md | 2 + .../pin-input-provider.svelte.test.ts | 286 ++++++++++++++++++ 3 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 src/uix/soma/components/pin-input/pin-input-provider.svelte.test.ts diff --git a/continue.md b/continue.md index 183fdbcd2..6f142d46b 100644 --- a/continue.md +++ b/continue.md @@ -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. diff --git a/src/uix/soma/SOMA_ARCHITECTURE.md b/src/uix/soma/SOMA_ARCHITECTURE.md index 7c524be18..f3b8c4e71 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -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`, diff --git a/src/uix/soma/components/pin-input/pin-input-provider.svelte.test.ts b/src/uix/soma/components/pin-input/pin-input-provider.svelte.test.ts new file mode 100644 index 000000000..664ac8c99 --- /dev/null +++ b/src/uix/soma/components/pin-input/pin-input-provider.svelte.test.ts @@ -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(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) => + 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(root), + inputId: state('pin-input-hidden'), + value: state('12'), + length: state(4), + type: state<'numeric' | 'alphanumeric' | 'alphabetic' | 'custom'>('numeric'), + pattern: state(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('code'), + autocomplete: state(undefined), + inputmode: state<'text' | 'numeric' | 'tel' | 'decimal' | undefined>(undefined), + ariaLabel: state(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(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(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(filledCellEl), + index: state(0), + cell: state(provider.cells[0]) + }); + const active = PinInputCellProvider.create({ + id: state('pin-input-cell-2'), + ref: state(activeCellEl), + index: state(2), + cell: state(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(); + }); +});