From ccded8bc4a63eeb355d9dd7d955371a2b919307f Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 16 May 2026 03:25:52 +0200 Subject: [PATCH] Add form provider coverage --- continue.md | 5 +- src/uix/soma/SOMA_ARCHITECTURE.md | 2 + .../form/form-provider.svelte.test.ts | 221 ++++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/uix/soma/components/form/form-provider.svelte.test.ts diff --git a/continue.md b/continue.md index e7c464bd0..1618c4998 100644 --- a/continue.md +++ b/continue.md @@ -139,9 +139,11 @@ Actualizacion 2026-05-15: touch guard, content hover/cancelClose y props Floating/Arrow. - Cobertura nueva para Table: root/section/header/cell props desde `$libs/datagrid`, selección de fila y disclosure `RowDetail`. + - Cobertura nueva para Form: submit inválido con foco via `ActiveDom`, + attrs runtime y partes Submit/Reset/ErrorSummary sobre `$libs/forms`. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 230 archivos, 2236 tests OK. + - `npm run test` -> 231 archivos, 2238 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. @@ -200,6 +202,7 @@ Actualizacion 2026-05-15: - `grid-list-provider.svelte.test.ts` -> 3 tests OK. - `link-preview-provider.svelte.test.ts` -> 2 tests OK. - `table-provider.svelte.test.ts` -> 2 tests OK. + - `form-provider.svelte.test.ts` -> 2 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 be5c5d5a5..36dd3edc2 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -571,6 +571,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A con `uix.timers`, touch guard, content hover y props Floating/Arrow. - `table/table-provider.svelte.test.ts` — root/section/header/cell props desde `$libs/datagrid`, selección de fila y disclosure `RowDetail`. +- `form/form-provider.svelte.test.ts` — submit inválido con foco via + `ActiveDom`, attrs runtime y partes Submit/Reset/ErrorSummary. 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/form/form-provider.svelte.test.ts b/src/uix/soma/components/form/form-provider.svelte.test.ts new file mode 100644 index 000000000..998dc2d9d --- /dev/null +++ b/src/uix/soma/components/form/form-provider.svelte.test.ts @@ -0,0 +1,221 @@ +// @vitest-environment jsdom + +import { tick } from 'svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createActiveDom } from '$adom'; +import { createForm, type Form } from '$libs/forms'; +import { state } from '$libs/reactive'; +import type { StandardSchemaV1 } from '$libs/standard-schema'; +import type { Morfo } from '$uix/morfo'; +import { Soma } from '$soma/core/soma.svelte'; +import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; + +import { + FormErrorSummaryProvider, + FormProvider, + FormResetProvider, + FormSubmitProvider +} from './form-provider.svelte'; + +type LoginValues = { + email: string; +}; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +function loginSchema(): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate(value) { + const values = value as LoginValues; + if (values.email.includes('@')) return { value: values }; + return { + issues: [ + { + message: 'Invalid email', + path: ['email'] + } + ] + }; + } + } + }; +} + +function createLoginForm() { + const form = createForm({ + defaults: { email: '' }, + schema: loginSchema() + }); + form.registerField('email', {}); + return form; +} + +function installSomaHarness() { + const dom = createActiveDom(); + const soma = { + dom, + langs: { + ts: vi.fn((key: string) => (key.includes('reset') ? 'Reset' : 'Submit')) + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(FormProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function formOpts(form: Form, root: HTMLFormElement) { + return { + id: state('login-form'), + ref: state(root), + form: state(form), + schema: state(loginSchema()), + noValidate: state(true), + focusFirstError: state(true) + }; +} + +function submitEvent() { + return { + preventDefault: vi.fn() + }; +} + +describe('FormProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('submits through the form handle and focuses the first invalid field', async () => { + const { dom } = installSomaHarness(); + const focusSpy = vi.spyOn(dom, 'focus'); + const root = document.createElement('form'); + const field = document.createElement('div'); + const input = document.createElement('input'); + field.setAttribute('data-field', ''); + field.setAttribute('data-field-name', 'email'); + field.appendChild(input); + root.appendChild(field); + document.body.appendChild(root); + const form = createLoginForm(); + const opts = formOpts(form, root); + + const { result: provider, cleanup } = withEffectRoot(() => FormProvider.create(opts)); + + expect(provider.props).toMatchObject({ + id: 'login-form', + 'data-form': '', + novalidate: true, + onsubmit: provider.handleSubmit + }); + expect(provider.getFieldElementId('email')).toBe('login-form-field-email'); + expect(provider.snippetProps).toMatchObject({ + isPending: false, + isValid: true, + isDirty: false, + isTouched: false, + submitCount: 0 + }); + + const event = submitEvent(); + await provider.handleSubmit(event as unknown as SubmitEvent); + await tick(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(form.submitCount).toBe(1); + expect(provider.isSubmitted).toBe(true); + expect(provider.isInvalid).toBe(true); + expect(focusSpy).toHaveBeenCalledWith(input); + expect(root.getAttribute('data-invalid')).toBe(''); + expect(root.getAttribute('data-submitted')).toBe(''); + + cleanup(); + dom.dispose(); + }); + + it('projects submit/reset/error-summary state from the form', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('form'); + document.body.appendChild(root); + const form = createLoginForm(); + form.values.email = 'bad'; + form.validate(); + const opts = formOpts(form, root); + opts.focusFirstError.current = false; + + const { result, cleanup } = withEffectRoot(() => { + const provider = FormProvider.create(opts); + vi.spyOn(FormProvider, 'require').mockReturnValue(provider); + const submit = FormSubmitProvider.create({ + id: state('form-submit'), + ref: state(document.createElement('button')), + ariaLabel: state(undefined) + }); + const reset = FormResetProvider.create({ + id: state('form-reset'), + ref: state(document.createElement('button')), + ariaLabel: state('Clear form'), + disableWhenPristine: state(true) + }); + const summary = FormErrorSummaryProvider.create({ + id: state('form-summary'), + ref: state(document.createElement('div')), + hideUntilSubmitted: state(true) + }); + return { provider, submit, reset, summary }; + }); + + expect(result.submit.props).toMatchObject({ + type: 'submit', + 'aria-label': 'Submit', + 'aria-busy': 'false', + disabled: undefined + }); + expect(result.reset.props).toMatchObject({ + type: 'button', + 'aria-label': 'Clear form', + disabled: undefined, + 'data-dirty': '' + }); + expect(result.summary.count).toBe(1); + expect(result.summary.fieldErrors).toEqual([{ field: 'email', message: 'Invalid email' }]); + expect(result.summary.props).toMatchObject({ + role: 'alert', + 'aria-live': 'polite', + hidden: true, + 'data-hidden': '' + }); + + await result.provider.handleSubmit(submitEvent() as unknown as SubmitEvent); + await tick(); + expect(result.summary.props.hidden).toBeUndefined(); + expect(result.summary.props['data-hidden']).toBeUndefined(); + + result.reset.onclick(); + await tick(); + expect(form.values.email).toBe(''); + expect(result.reset.props.disabled).toBe(true); + expect(result.summary.count).toBe(0); + expect(result.summary.props.hidden).toBe(true); + + cleanup(); + dom.dispose(); + }); +});