Add tooltip provider coverage

active-uix
dev 5 months ago
parent e22b418324
commit 6a5152a5d7

@ -97,9 +97,11 @@ Actualizacion 2026-05-15:
`expand/collapse` y guardas disabled.
- Cobertura nueva para Clipboard: copia via `uix.clipboard`, reset con
`uix.timers`, labels traducidos/override y errores `onError`.
- Cobertura nueva para Tooltip: delay/close con `uix.timers`, skip-delay
de grupo, focus/blur, disabled y partes trigger/content/arrow.
- Validado por fase:
- `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 209 archivos, 2175 tests OK.
- `npm run test` -> 210 archivos, 2178 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.
@ -137,6 +139,7 @@ Actualizacion 2026-05-15:
-> 4 tests OK.
- `collapsible-provider.svelte.test.ts` -> 3 tests OK.
- `clipboard-provider.svelte.test.ts` -> 2 tests OK.
- `tooltip-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.

@ -529,6 +529,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
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`.
- `tooltip/tooltip-provider.svelte.test.ts` — delay/close con `uix.timers`,
skip-delay de grupo, focus/blur, disabled y partes trigger/content/arrow.
Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por
componentes de riesgo medio y familias menos centrales. `table-core`,

@ -0,0 +1,271 @@
// @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 { FloatingContent, FloatingProvider } from '$soma/layers/floating';
import {
TooltipArrowProvider,
TooltipContentProvider,
TooltipGroupProvider,
TooltipProvider,
TooltipTriggerProvider
} from './tooltip-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const tasks = new Map<string, () => void>();
const handles = new Map<
string,
{ cancel: ReturnType<typeof vi.fn>; reschedule: ReturnType<typeof vi.fn> }
>();
const schedule = vi.fn((key: string, _delay: number, task: () => void) => {
tasks.set(key, task);
const handle = {
cancel: vi.fn(() => tasks.delete(key)),
reschedule: vi.fn()
};
handles.set(key, handle);
return handle;
});
const soma = {
dom,
uix: {
timers: { schedule }
},
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => key,
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(TooltipGroupProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(TooltipProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(FloatingProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(FloatingContent.ctx, 'set').mockImplementation((value) => value);
const groupGet = vi.spyOn(TooltipGroupProvider, 'get').mockReturnValue(undefined);
return { dom, schedule, tasks, handles, groupGet };
}
function groupOpts() {
return {
delayDuration: state(700),
skipDelayDuration: state(300),
disableHoverableContent: state(false),
disabled: state(false)
};
}
function tooltipOpts() {
return {
id: state('tooltip-root'),
ref: state<HTMLElement | null>(null),
open: state(false),
delayDuration: state<number | undefined>(undefined),
closeDelay: state(150),
disableHoverableContent: state<boolean | undefined>(undefined),
disabled: state<boolean | undefined>(undefined),
onOpenChangeComplete: state<(open: boolean) => void>(() => undefined)
};
}
function contentOpts(contentEl: HTMLElement) {
return {
id: state('tooltip-content'),
ref: state<HTMLElement | null>(contentEl),
side: state<'top'>('top'),
sideOffset: state(0),
align: state<'center'>('center'),
alignOffset: state(0),
arrowPadding: state(0),
avoidCollisions: state(false),
collisionBoundary: state([]),
collisionPadding: state(8),
sticky: state<'partial'>('partial'),
hideWhenDetached: state(false),
updatePositionStrategy: state<'optimized'>('optimized'),
strategy: state<'fixed'>('fixed'),
dir: state<'ltr'>('ltr'),
style: state(null),
ariaLabel: state<string | undefined>('Helpful hint'),
onEscapeKeydown: state<(e: KeyboardEvent) => void>(() => undefined),
escapeKeydownBehavior: state<'close'>('close')
};
}
describe('TooltipProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('uses delayed open, delayed close and group skip-delay timers', async () => {
const harness = installSomaHarness();
const triggerEl = document.createElement('button');
document.body.appendChild(triggerEl);
const opts = tooltipOpts();
const { result, cleanup } = withEffectRoot(() => {
const group = TooltipGroupProvider.create(groupOpts());
harness.groupGet.mockReturnValue(group);
const provider = TooltipProvider.create(opts);
vi.spyOn(TooltipProvider, 'require').mockReturnValue(provider);
vi.spyOn(FloatingProvider.ctx, 'get').mockReturnValue(provider.floatingProvider);
const trigger = TooltipTriggerProvider.create({
id: state('tooltip-trigger'),
ref: state<HTMLElement | null>(triggerEl)
});
return { provider, trigger };
});
result.trigger.onpointerenter();
expect(result.provider.tooltipState).toBe('closed');
expect(opts.open.current).toBe(false);
expect(harness.schedule).toHaveBeenCalledWith(
'soma:tooltip:tooltip-root:open',
700,
expect.any(Function),
expect.objectContaining({ replace: true })
);
harness.tasks.get('soma:tooltip:tooltip-root:open')?.();
await tick();
expect(opts.open.current).toBe(true);
expect(result.provider.tooltipState).toBe('instant-open');
expect(triggerEl.getAttribute('data-state')).toBe('instant-open');
result.trigger.onpointerleave();
expect(harness.schedule).toHaveBeenCalledWith(
'soma:tooltip:tooltip-root:close',
150,
expect.any(Function),
expect.objectContaining({ replace: true })
);
harness.tasks.get('soma:tooltip:tooltip-root:close')?.();
await tick();
expect(opts.open.current).toBe(false);
expect(result.provider.tooltipState).toBe('closed');
result.trigger.onpointerenter();
expect(opts.open.current).toBe(true);
expect(result.provider.tooltipState).toBe('instant-open');
cleanup();
harness.dom.dispose();
});
it('opens instantly on focus, closes on blur and respects disabled state', async () => {
const harness = installSomaHarness();
const triggerEl = document.createElement('button');
document.body.appendChild(triggerEl);
const opts = tooltipOpts();
opts.closeDelay.current = 0;
const groupConfig = groupOpts();
const { result, cleanup } = withEffectRoot(() => {
const group = TooltipGroupProvider.create(groupConfig);
harness.groupGet.mockReturnValue(group);
const provider = TooltipProvider.create(opts);
vi.spyOn(TooltipProvider, 'require').mockReturnValue(provider);
vi.spyOn(FloatingProvider.ctx, 'get').mockReturnValue(provider.floatingProvider);
const trigger = TooltipTriggerProvider.create({
id: state('tooltip-trigger'),
ref: state<HTMLElement | null>(triggerEl)
});
return { group, provider, trigger };
});
result.trigger.onfocus();
await tick();
expect(opts.open.current).toBe(true);
expect(result.provider.tooltipState).toBe('instant-open');
result.trigger.onblur();
await tick();
expect(opts.open.current).toBe(false);
expect(result.provider.tooltipState).toBe('closed');
groupConfig.disabled.current = true;
result.trigger.onfocus();
await tick();
expect(opts.open.current).toBe(false);
cleanup();
harness.dom.dispose();
});
it('registers trigger/content/arrow parts and links content while open', async () => {
const harness = installSomaHarness();
const triggerEl = document.createElement('button');
const contentEl = document.createElement('div');
const arrowEl = document.createElement('div');
document.body.append(triggerEl, contentEl, arrowEl);
const opts = tooltipOpts();
opts.open.current = true;
const { result, cleanup } = withEffectRoot(() => {
const provider = TooltipProvider.create(opts);
vi.spyOn(TooltipProvider, 'require').mockReturnValue(provider);
vi.spyOn(FloatingProvider.ctx, 'get').mockReturnValue(provider.floatingProvider);
const trigger = TooltipTriggerProvider.create({
id: state('tooltip-trigger'),
ref: state<HTMLElement | null>(triggerEl)
});
const content = TooltipContentProvider.create(contentOpts(contentEl));
vi.spyOn(FloatingContent.ctx, 'get').mockReturnValue(content.floating);
const arrow = TooltipArrowProvider.create({
id: state('tooltip-arrow'),
ref: state<HTMLElement | null>(arrowEl)
});
return { provider, trigger, content, arrow };
});
expect(result.trigger.props).toMatchObject({
id: 'tooltip-trigger',
'data-tooltip-trigger': '',
tabindex: 0
});
expect(result.content.props).toMatchObject({
id: 'tooltip-content',
'data-tooltip-content': ''
});
expect(result.content.wrapperProps).toMatchObject({
id: 'tooltip-content-wrapper',
'data-soma-floating-wrapper': '',
dir: 'ltr'
});
expect(result.arrow.props).toMatchObject({
id: 'tooltip-arrow',
'data-tooltip-arrow': ''
});
expect(result.provider.triggerId.current).toBe('tooltip-trigger');
expect(result.provider.contentId.current).toBe('tooltip-content');
await tick();
expect(triggerEl.getAttribute('aria-describedby')).toBe('tooltip-content');
expect(contentEl.getAttribute('role')).toBe('tooltip');
expect(contentEl.getAttribute('aria-label')).toBe('Helpful hint');
expect(contentEl.getAttribute('data-state')).toBe('instant-open');
cleanup();
harness.dom.dispose();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.