diff --git a/continue.md b/continue.md index 5fd59435e..6ee7d4a6c 100644 --- a/continue.md +++ b/continue.md @@ -80,9 +80,11 @@ Actualizacion 2026-05-15: 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. + - Cobertura nueva para Switch: commit-toggle runtime, keyboard, hidden input, + integración con FieldProvider y thumb. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 200 archivos, 2145 tests OK. + - `npm run test` -> 201 archivos, 2150 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. @@ -111,6 +113,7 @@ Actualizacion 2026-05-15: - `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. + - `switch-provider.svelte.test.ts` -> 5 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 717afe07c..5336e346a 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -512,12 +512,14 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A 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. +- `switch/switch-provider.svelte.test.ts` — `commit-toggle` runtime, + keyboard, hidden input, integración con `FieldProvider` y thumb. 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/radio-group. `table-core`, `form-core` y el scorer de -Command ya no viven dentro de Soma: se consumen desde `$libs/datagrid`, +accordion/checkbox/radio-group/switch. `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 diff --git a/src/uix/soma/components/switch/switch-provider.svelte.test.ts b/src/uix/soma/components/switch/switch-provider.svelte.test.ts new file mode 100644 index 000000000..835079b10 --- /dev/null +++ b/src/uix/soma/components/switch/switch-provider.svelte.test.ts @@ -0,0 +1,213 @@ +// @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 '../field/field-provider.svelte'; + +import { SwitchProvider, SwitchThumbProvider } from './switch-provider.svelte'; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +async function flushRuntimeTrigger() { + await Promise.resolve(); + await tick(); +} + +function installSomaHarness() { + const dom = createActiveDom(); + const soma = { + dom, + 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(undefined); + vi.spyOn(SwitchProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function switchOpts(id = 'switch-root') { + return { + id: state(id), + ref: state(document.createElement('button')), + checked: state(false), + disabled: state(false), + readonly: state(false), + required: state(false), + invalid: state(false), + intent: state<'neutral' | 'affirm' | 'risk' | 'threat'>('neutral'), + color: state<'primary' | 'secondary'>('primary'), + name: state(''), + value: state('on'), + 'aria-label': state(''), + 'aria-labelledby': state(''), + onCheckedChange: state<(checked: boolean) => void>(() => undefined) + }; +} + +function keyEvent(key: string) { + return { + key, + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + preventDefault: vi.fn() + }; +} + +describe('SwitchProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('toggles checked state from click and keyboard through the runtime event', async () => { + const { dom } = installSomaHarness(); + const opts = switchOpts(); + const onCheckedChange = vi.fn(); + opts.onCheckedChange.current = onCheckedChange; + + const { result: provider, cleanup } = withEffectRoot(() => SwitchProvider.create(opts)); + + expect(provider.props).toMatchObject({ + id: 'switch-root', + disabled: undefined + }); + expect(provider.snippetProps).toEqual({ checked: false }); + + provider.onclick({} as never); + await flushRuntimeTrigger(); + expect(opts.checked.current).toBe(true); + expect(onCheckedChange).toHaveBeenCalledWith(true); + expect(provider.snippetProps).toEqual({ checked: true }); + + const event = keyEvent(' '); + provider.onkeydown(event as never); + await flushRuntimeTrigger(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(opts.checked.current).toBe(false); + expect(onCheckedChange).toHaveBeenLastCalledWith(false); + + cleanup(); + dom.dispose(); + }); + + it('exposes hidden input props for native form participation', async () => { + const { dom } = installSomaHarness(); + const opts = switchOpts(); + opts.name.current = 'notifications'; + opts.value.current = 'enabled'; + opts.required.current = true; + opts.checked.current = true; + + const { result: provider, cleanup } = withEffectRoot(() => SwitchProvider.create(opts)); + + expect(provider.inputProps).toMatchObject({ + type: 'checkbox', + 'aria-hidden': true, + tabindex: -1, + name: 'notifications', + value: 'enabled', + checked: true, + required: true, + disabled: undefined + }); + + opts.checked.current = false; + await tick(); + expect(provider.inputProps.checked).toBe(false); + + cleanup(); + dom.dispose(); + }); + + it('merges FieldProvider flags and registers itself as field input', async () => { + const { dom } = installSomaHarness(); + const field = { + inputId: state(''), + isDisabled: true, + isReadonly: false, + isRequired: true, + isInvalid: true + }; + vi.spyOn(FieldProvider, 'get').mockReturnValue(field as FieldProvider); + const opts = switchOpts('field-switch'); + + const { result: provider, cleanup } = withEffectRoot(() => SwitchProvider.create(opts)); + + expect(field.inputId.current).toBe('field-switch'); + expect(provider.isDisabled).toBe(true); + expect(provider.isRequired).toBe(true); + expect(provider.isInvalid).toBe(true); + expect(provider.props.disabled).toBe(true); + expect(provider.inputProps).toMatchObject({ + required: true, + disabled: true + }); + + provider.toggle(); + await flushRuntimeTrigger(); + expect(opts.checked.current).toBe(false); + + cleanup(); + dom.dispose(); + }); + + it('keeps readonly switches focusable but blocks state changes', async () => { + const { dom } = installSomaHarness(); + const opts = switchOpts(); + opts.readonly.current = true; + + const { result: provider, cleanup } = withEffectRoot(() => SwitchProvider.create(opts)); + + provider.toggle(); + await flushRuntimeTrigger(); + expect(opts.checked.current).toBe(false); + expect(provider.props.disabled).toBeUndefined(); + + cleanup(); + dom.dispose(); + }); + + it('wires thumb props to the parent runtime', () => { + const { dom } = installSomaHarness(); + const opts = switchOpts(); + + const { result, cleanup } = withEffectRoot(() => { + const provider = SwitchProvider.create(opts); + vi.spyOn(SwitchProvider, 'require').mockReturnValue(provider); + const thumb = SwitchThumbProvider.create({ + id: state('switch-thumb'), + ref: state(document.createElement('span')) + }); + return { thumb }; + }); + + expect(result.thumb.props).toMatchObject({ + id: 'switch-thumb', + 'data-switch-thumb': '' + }); + + cleanup(); + dom.dispose(); + }); +});