diff --git a/continue.md b/continue.md index 45ae70d6d..dd8c9e709 100644 --- a/continue.md +++ b/continue.md @@ -95,9 +95,11 @@ Actualizacion 2026-05-15: emitidos por morfo/runtime, estados derivados e indicador sincronizado. - Cobertura nueva para Collapsible: refs cruzadas trigger/content, eventos `expand/collapse` y guardas disabled. + - Cobertura nueva para Clipboard: copia via `uix.clipboard`, reset con + `uix.timers`, labels traducidos/override y errores `onError`. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 208 archivos, 2173 tests OK. + - `npm run test` -> 209 archivos, 2175 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. @@ -134,6 +136,7 @@ Actualizacion 2026-05-15: - `progress-provider.svelte.test.ts` + `meter-provider.svelte.test.ts` -> 4 tests OK. - `collapsible-provider.svelte.test.ts` -> 3 tests OK. + - `clipboard-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 446926324..c674d1396 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -527,6 +527,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A emitidos por morfo/runtime, estados derivados e indicador sincronizado. - `collapsible/collapsible-provider.svelte.test.ts` — refs cruzadas trigger/content, eventos `expand/collapse` y guardas disabled. +- `clipboard/clipboard-provider.svelte.test.ts` — copia via `uix.clipboard`, + reset con `uix.timers`, labels traducidos/override y errores `onError`. 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/clipboard/clipboard-provider.svelte.test.ts b/src/uix/soma/components/clipboard/clipboard-provider.svelte.test.ts new file mode 100644 index 000000000..3c4d2cf05 --- /dev/null +++ b/src/uix/soma/components/clipboard/clipboard-provider.svelte.test.ts @@ -0,0 +1,188 @@ +// @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 { + ClipboardIndicatorProvider, + ClipboardProvider, + ClipboardTriggerProvider +} from './clipboard-provider.svelte'; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +function installSomaHarness(writeText = vi.fn<() => Promise>().mockResolvedValue(undefined)) { + const dom = createActiveDom(); + const timerCancel = vi.fn(); + const timerReschedule = vi.fn(); + let scheduledTask: (() => void) | undefined; + const schedule = vi.fn((_key: string, _delay: number, task: () => void) => { + scheduledTask = task; + return { + cancel: timerCancel, + reschedule: timerReschedule + }; + }); + const soma = { + dom, + langs: { + ts: vi.fn((key: string) => (key.includes('copied') ? 'Copied' : 'Copy to clipboard')) + }, + uix: { + clipboard: { writeText }, + timers: { schedule } + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(ClipboardProvider.ctx, 'set').mockImplementation((value) => value); + + return { + dom, + writeText, + schedule, + timerCancel, + get scheduledTask() { + return scheduledTask; + } + }; +} + +function clipboardOpts(root = document.createElement('div')) { + return { + id: state('clipboard-root'), + ref: state(root), + value: state('copy me'), + timeout: state(1500), + onCopy: state<((value: string) => void) | undefined>(undefined), + onError: state<((err: unknown) => void) | undefined>(undefined) + }; +} + +describe('ClipboardProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('copies value, projects copied state and resets through UIX timers', async () => { + const harness = installSomaHarness(); + const root = document.createElement('div'); + const triggerEl = document.createElement('button'); + const indicatorEl = document.createElement('span'); + document.body.append(root, triggerEl, indicatorEl); + const opts = clipboardOpts(root); + const onCopy = vi.fn(); + opts.onCopy.current = onCopy; + + const { result, cleanup } = withEffectRoot(() => { + const provider = ClipboardProvider.create(opts); + vi.spyOn(ClipboardProvider, 'require').mockReturnValue(provider); + const trigger = ClipboardTriggerProvider.create({ + id: state('clipboard-trigger'), + ref: state(triggerEl), + ariaLabel: state(undefined) + }); + const indicator = ClipboardIndicatorProvider.create({ + id: state('clipboard-indicator'), + ref: state(indicatorEl) + }); + return { provider, trigger, indicator }; + }); + + expect(result.provider.snippetProps).toMatchObject({ + value: 'copy me', + copied: false + }); + expect(result.indicator.isPresent).toBe(false); + + await tick(); + expect(triggerEl.getAttribute('aria-label')).toBe('Copy to clipboard'); + expect(root.hasAttribute('data-copied')).toBe(false); + + await result.provider.copy(); + expect(harness.writeText).toHaveBeenCalledWith('copy me'); + expect(onCopy).toHaveBeenCalledWith('copy me'); + expect(result.provider.copied).toBe(true); + expect(result.indicator.isPresent).toBe(true); + expect(harness.schedule).toHaveBeenCalledWith( + 'soma:clipboard:clipboard-root:reset', + 1500, + expect.any(Function), + expect.objectContaining({ + replace: true, + meta: { component: 'clipboard', action: 'reset-copied' } + }) + ); + + await tick(); + expect(root.getAttribute('data-copied')).toBe(''); + expect(triggerEl.getAttribute('data-copied')).toBe(''); + expect(triggerEl.getAttribute('aria-label')).toBe('Copied'); + expect(indicatorEl.getAttribute('data-copied')).toBe(''); + + harness.scheduledTask?.(); + await tick(); + expect(result.provider.copied).toBe(false); + expect(root.hasAttribute('data-copied')).toBe(false); + expect(triggerEl.getAttribute('aria-label')).toBe('Copy to clipboard'); + + cleanup(); + harness.dom.dispose(); + }); + + it('uses trigger label overrides and reports clipboard errors', async () => { + const error = new Error('denied'); + const harness = installSomaHarness(vi.fn<() => Promise>().mockRejectedValue(error)); + const root = document.createElement('div'); + const triggerEl = document.createElement('button'); + document.body.append(root, triggerEl); + const opts = clipboardOpts(root); + const onError = vi.fn(); + opts.onError.current = onError; + + const { result, cleanup } = withEffectRoot(() => { + const provider = ClipboardProvider.create(opts); + vi.spyOn(ClipboardProvider, 'require').mockReturnValue(provider); + const trigger = ClipboardTriggerProvider.create({ + id: state('clipboard-trigger'), + ref: state(triggerEl), + ariaLabel: state('Copy code') + }); + return { provider, trigger }; + }); + + await tick(); + expect(triggerEl.getAttribute('aria-label')).toBe('Copy code'); + + await expect(result.provider.copy()).rejects.toThrow('denied'); + expect(onError).toHaveBeenCalledWith(error); + expect(result.provider.copied).toBe(false); + expect(harness.schedule).not.toHaveBeenCalled(); + + result.trigger.onclick({ currentTarget: triggerEl } as never); + await Promise.resolve(); + expect(onError).toHaveBeenCalledTimes(2); + + cleanup(); + harness.dom.dispose(); + }); +});