From 4b5d9078ed24ea50b828bdc4dc759ec05c5749d3 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 16 May 2026 02:11:09 +0200 Subject: [PATCH] Add splitter provider coverage --- continue.md | 5 +- src/uix/soma/SOMA_ARCHITECTURE.md | 2 + .../splitter/splitter-provider.svelte.test.ts | 257 ++++++++++++++++++ 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/uix/soma/components/splitter/splitter-provider.svelte.test.ts diff --git a/continue.md b/continue.md index 21e60a45c..183fdbcd2 100644 --- a/continue.md +++ b/continue.md @@ -113,9 +113,11 @@ Actualizacion 2026-05-15: selección lineal/no lineal, teclado y prev/next/completed. - Cobertura nueva para ScrollArea: medición de overflow, viewport, 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. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 217 archivos, 2200 tests OK. + - `npm run test` -> 218 archivos, 2203 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. @@ -161,6 +163,7 @@ Actualizacion 2026-05-15: - `toolbar-provider.svelte.test.ts` -> 3 tests OK. - `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. - 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 17bc6c6f2..7c524be18 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -545,6 +545,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A attrs runtime, selección lineal/no lineal, teclado y prev/next/completed. - `scroll-area/scroll-area-provider.svelte.test.ts` — medición de overflow, 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. 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/splitter/splitter-provider.svelte.test.ts b/src/uix/soma/components/splitter/splitter-provider.svelte.test.ts new file mode 100644 index 000000000..15cf28392 --- /dev/null +++ b/src/uix/soma/components/splitter/splitter-provider.svelte.test.ts @@ -0,0 +1,257 @@ +// @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 { + SplitterPanelProvider, + SplitterProvider, + SplitterResizeTriggerProvider +} from './splitter-provider.svelte'; + +function withEffectRoot(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) => + createSomaRuntime(morfo, { + dom, + translate: (key) => (key.includes('splitter.resize') ? 'Resize panels' : key), + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(SplitterProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function splitterOpts(root = document.createElement('div')) { + return { + id: state('splitter-root'), + ref: state(root), + orientation: state<'horizontal' | 'vertical'>('horizontal'), + disabled: state(false), + dir: state<'ltr' | 'rtl'>('ltr'), + keyboardStep: state(5), + onResize: state(vi.fn()), + onResizeEnd: state(vi.fn()) + }; +} + +function panelOpts(panelId: string, el: HTMLElement, defaultSize: number) { + return { + id: state(`splitter-panel-${panelId}`), + ref: state(el), + panelId: state(panelId), + defaultSize: state(defaultSize), + minSize: state(10), + maxSize: state(90), + collapsible: state(false), + collapsedSize: state(0) + }; +} + +function pointerEvent( + overrides: Partial & { + currentTarget?: EventTarget; + clientX?: number; + clientY?: number; + button?: number; + pointerId?: number; + } = {} +) { + return { + button: 0, + clientX: 0, + clientY: 0, + pointerId: 1, + currentTarget: document.createElement('div'), + preventDefault: vi.fn(), + ...overrides + }; +} + +function keyEvent(key: string) { + return { + key, + preventDefault: vi.fn() + }; +} + +describe('SplitterProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('registers panels, clamps resize and projects root/panel attrs', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + const panelAEl = document.createElement('div'); + const panelBEl = document.createElement('div'); + document.body.append(root, panelAEl, panelBEl); + const opts = splitterOpts(root); + + const { result, cleanup } = withEffectRoot(() => { + const provider = SplitterProvider.create(opts); + vi.spyOn(SplitterProvider, 'require').mockReturnValue(provider); + const panelA = SplitterPanelProvider.create(panelOpts('a', panelAEl, 40)); + const panelB = SplitterPanelProvider.create(panelOpts('b', panelBEl, 60)); + return { provider, panelA, panelB }; + }); + + expect(result.provider.getSizes()).toEqual([40, 60]); + expect(result.panelA.props.style).toMatchObject({ + 'flex-basis': '0px', + 'flex-grow': 40, + 'flex-shrink': 0, + 'min-width': '0px' + }); + expect(result.panelB.isCollapsed).toBe(false); + + result.provider.resizePanels('a', 'b', 70); + expect(result.provider.getSizes()).toEqual([90, 10]); + expect(opts.onResize.current).toHaveBeenLastCalledWith([90, 10]); + + opts.disabled.current = true; + result.provider.resizePanels('a', 'b', -50); + expect(result.provider.getSizes()).toEqual([90, 10]); + + await tick(); + expect(root.getAttribute('data-orientation')).toBe('horizontal'); + expect(root.getAttribute('data-disabled')).toBe(''); + expect(panelAEl.getAttribute('data-state')).toBe('expanded'); + expect(panelAEl.getAttribute('data-orientation')).toBe('horizontal'); + expect(panelAEl.getAttribute('data-splitter-panel-id')).toBe('a'); + expect(panelBEl.getAttribute('data-splitter-panel-id')).toBe('b'); + + cleanup(); + dom.dispose(); + }); + + it('collapses and expands collapsible panels while respecting adjacent constraints', () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + const panelAEl = document.createElement('div'); + const panelBEl = document.createElement('div'); + document.body.append(root, panelAEl, panelBEl); + const opts = splitterOpts(root); + const firstOpts = panelOpts('a', panelAEl, 30); + firstOpts.collapsible.current = true; + firstOpts.collapsedSize.current = 0; + const secondOpts = panelOpts('b', panelBEl, 70); + secondOpts.maxSize.current = 100; + + const { result, cleanup } = withEffectRoot(() => { + const provider = SplitterProvider.create(opts); + vi.spyOn(SplitterProvider, 'require').mockReturnValue(provider); + const panelA = SplitterPanelProvider.create(firstOpts); + const panelB = SplitterPanelProvider.create(secondOpts); + return { provider, panelA, panelB }; + }); + + result.provider.collapsePanel('a'); + expect(result.provider.getSizes()).toEqual([0, 100]); + expect(result.panelA.isCollapsed).toBe(true); + expect(result.panelA.props.style['flex-grow']).toBe(0); + expect(opts.onResize.current).toHaveBeenLastCalledWith([0, 100]); + + result.provider.expandPanel('a'); + expect(result.provider.getSizes()).toEqual([30, 70]); + expect(result.panelA.isCollapsed).toBe(false); + expect(result.panelB.props.style['flex-grow']).toBe(70); + + cleanup(); + dom.dispose(); + }); + + it('resizes with pointer and keyboard through the resize trigger', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + const panelAEl = document.createElement('div'); + const panelBEl = document.createElement('div'); + const triggerEl = document.createElement('div'); + const setPointerCapture = vi.fn(); + triggerEl.setPointerCapture = setPointerCapture; + root.getBoundingClientRect = () => ({ width: 400, height: 100 }) as DOMRect; + document.body.append(root, panelAEl, panelBEl, triggerEl); + const opts = splitterOpts(root); + + const { result, cleanup } = withEffectRoot(() => { + const provider = SplitterProvider.create(opts); + vi.spyOn(SplitterProvider, 'require').mockReturnValue(provider); + SplitterPanelProvider.create(panelOpts('a', panelAEl, 50)); + SplitterPanelProvider.create(panelOpts('b', panelBEl, 50)); + const trigger = SplitterResizeTriggerProvider.create({ + id: state('splitter-trigger'), + ref: state(triggerEl), + panelBefore: state('a'), + panelAfter: state('b'), + disabled: state(false) + }); + return { provider, trigger }; + }); + + const down = pointerEvent({ currentTarget: triggerEl, clientX: 100, pointerId: 2 }); + result.trigger.onpointerdown(down as never); + expect(down.preventDefault).toHaveBeenCalledOnce(); + expect(setPointerCapture).toHaveBeenCalledWith(2); + expect(result.trigger.dragging).toBe(true); + expect(result.provider.resizing).toBe(true); + + const move = pointerEvent({ clientX: 140 }); + result.trigger.onpointermove(move as never); + expect(move.preventDefault).toHaveBeenCalledOnce(); + expect(result.provider.getSizes()).toEqual([60, 40]); + expect(opts.onResize.current).toHaveBeenLastCalledWith([60, 40]); + + result.trigger.onpointerup(pointerEvent() as never); + expect(result.trigger.dragging).toBe(false); + expect(result.provider.resizing).toBe(false); + expect(opts.onResizeEnd.current).toHaveBeenLastCalledWith([60, 40]); + + const next = keyEvent('ArrowRight'); + result.trigger.onkeydown(next as never); + expect(next.preventDefault).toHaveBeenCalledOnce(); + expect(result.provider.getSizes()).toEqual([65, 35]); + expect(opts.onResizeEnd.current).toHaveBeenLastCalledWith([65, 35]); + + const home = keyEvent('Home'); + result.trigger.onkeydown(home as never); + expect(home.preventDefault).toHaveBeenCalledOnce(); + expect(result.provider.getSizes()).toEqual([10, 90]); + + const end = keyEvent('End'); + result.trigger.onkeydown(end as never); + expect(end.preventDefault).toHaveBeenCalledOnce(); + expect(result.provider.getSizes()).toEqual([90, 10]); + + await tick(); + expect(root.getAttribute('data-resizing')).toBeNull(); + expect(triggerEl.getAttribute('role')).toBe('separator'); + expect(triggerEl.getAttribute('aria-label')).toBe('Resize panels'); + expect(triggerEl.getAttribute('aria-valuenow')).toBe('90'); + expect(triggerEl.getAttribute('aria-valuemin')).toBe('10'); + expect(triggerEl.getAttribute('aria-valuemax')).toBe('90'); + expect(triggerEl.getAttribute('data-state')).toBe('inactive'); + + cleanup(); + dom.dispose(); + }); +});