Add toast provider coverage

active-uix
dev 5 months ago
parent ccded8bc4a
commit 2956895e04

@ -141,9 +141,11 @@ Actualizacion 2026-05-15:
`$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`.
- Cobertura nueva para ToastProvider: viewport hotkey via `ActiveDom`,
runtime por item, ARIA/parts, auto-dismiss y close action.
- Validado por fase:
- `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 231 archivos, 2238 tests OK.
- `npm run test` -> 232 archivos, 2240 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.
@ -203,6 +205,7 @@ Actualizacion 2026-05-15:
- `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.
- `toast-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.

@ -573,6 +573,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
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.
- `toast/toast-provider.svelte.test.ts` — viewport hotkey via `ActiveDom`,
runtime por item, ARIA/parts, auto-dismiss y close action.
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,248 @@
// @vitest-environment jsdom
import { tick } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state, type 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 {
ToastActionProvider,
ToastCloseProvider,
ToastDescriptionProvider,
ToastItemProvider,
ToastMainProvider,
ToastProvider,
ToastStatusProvider,
ToastTitleProvider,
ToastViewportProvider
} from './toast-provider.svelte';
import { Toaster, type ToastData } from './toaster.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,
langs: {
ts: vi.fn((key: string) => (key.includes('close') ? 'Close' : key))
},
uix: {
timers: { schedule }
},
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => soma.langs.ts(key),
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(ToastProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(ToastItemProvider.ctx, 'set').mockImplementation((value) => value);
return { dom, schedule, tasks, handles };
}
function toastProviderOpts(toaster: Toaster) {
return {
id: state('toast-root'),
ref: state<HTMLElement | null>(null),
toaster: state(toaster),
label: state('Notifications')
};
}
function itemOpts(toast: State<ToastData>, itemEl: HTMLElement) {
return {
id: state('toast-item'),
ref: state<HTMLElement | null>(itemEl),
toast
};
}
function keyboardEvent(key: string, code: string, init: KeyboardEventInit = {}) {
return new KeyboardEvent('keydown', {
key,
code,
bubbles: true,
cancelable: true,
...init
});
}
describe('ToastProvider parts', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('projects viewport attrs and focuses it through the toaster hotkey', async () => {
const { dom } = installSomaHarness();
const focusSpy = vi.spyOn(dom, 'focus');
const toaster = new Toaster({ duration: 0, hotkey: ['AltLeft', 'F8'] });
const viewportEl = document.createElement('div');
document.body.appendChild(viewportEl);
const { result, cleanup } = withEffectRoot(() => {
const provider = ToastProvider.create(toastProviderOpts(toaster));
vi.spyOn(ToastProvider, 'require').mockReturnValue(provider);
const viewport = ToastViewportProvider.create({
id: state('toast-viewport'),
ref: state<HTMLElement | null>(viewportEl)
});
return { provider, viewport };
});
expect(result.provider.toaster).toBe(toaster);
expect(result.viewport.props).toMatchObject({
id: 'toast-viewport',
'data-toast-viewport': '',
tabindex: -1
});
await tick();
expect(viewportEl.getAttribute('role')).toBe('region');
expect(viewportEl.getAttribute('aria-label')).toBe('Notifications');
expect(viewportEl.getAttribute('aria-live')).toBe('polite');
document.dispatchEvent(keyboardEvent('Alt', 'AltLeft', { altKey: true }));
const f8 = keyboardEvent('F8', 'F8', { altKey: true });
document.dispatchEvent(f8);
expect(f8.defaultPrevented).toBe(true);
expect(focusSpy).toHaveBeenCalledWith(viewportEl);
cleanup();
dom.dispose();
});
it('uses a per-item runtime for ARIA, timers and dismiss actions', async () => {
const harness = installSomaHarness();
const toaster = new Toaster({ duration: 100, swipeDirection: 'right', swipeThreshold: 50 });
const id = toaster.create({
title: 'Saved',
description: 'Record persisted',
intent: 'risk',
loading: true,
duration: 100
});
const toastState = state(toaster.toasts[0]!);
const itemEl = document.createElement('div');
const titleEl = document.createElement('div');
const descriptionEl = document.createElement('div');
const actionEl = document.createElement('button');
const closeEl = document.createElement('button');
const statusEl = document.createElement('span');
const mainEl = document.createElement('div');
document.body.append(itemEl, titleEl, descriptionEl, actionEl, closeEl, statusEl, mainEl);
const { result, cleanup } = withEffectRoot(() => {
const provider = ToastProvider.create(toastProviderOpts(toaster));
vi.spyOn(ToastProvider, 'require').mockReturnValue(provider);
const item = ToastItemProvider.create(itemOpts(toastState, itemEl));
vi.spyOn(ToastItemProvider, 'require').mockReturnValue(item);
const title = ToastTitleProvider.create({
id: state('toast-title'),
ref: state<HTMLElement | null>(titleEl)
});
const description = ToastDescriptionProvider.create({
id: state('toast-description'),
ref: state<HTMLElement | null>(descriptionEl)
});
const action = ToastActionProvider.create({
id: state('toast-action'),
ref: state<HTMLElement | null>(actionEl),
altText: state('Undo save')
});
const close = ToastCloseProvider.create({
id: state('toast-close'),
ref: state<HTMLElement | null>(closeEl)
});
const status = ToastStatusProvider.create({
id: state('toast-status'),
ref: state<HTMLElement | null>(statusEl)
});
const main = ToastMainProvider.create({
id: state('toast-main'),
ref: state<HTMLElement | null>(mainEl)
});
return { provider, item, title, description, action, close, status, main };
});
expect(harness.schedule).toHaveBeenCalledWith(
'soma:toast:auto-dismiss:toast-item',
100,
expect.any(Function),
expect.objectContaining({
replace: true,
meta: { component: 'toast', action: 'auto-dismiss' }
})
);
expect(result.item.props).toMatchObject({
id: 'toast-item',
'data-toast-item': '',
tabindex: 0
});
expect(result.title.props).toMatchObject({ id: 'toast-title', 'data-toast-title': '' });
expect(result.description.props).toMatchObject({
id: 'toast-description',
'data-toast-description': ''
});
expect(result.status.props).toMatchObject({ id: 'toast-status', 'data-toast-status': '' });
expect(result.main.props).toMatchObject({ id: 'toast-main', 'data-toast-main': '' });
expect(result.close.props.onclick).toBe(result.close.onclick);
await tick();
expect(itemEl.getAttribute('role')).toBe('alert');
expect(itemEl.getAttribute('aria-live')).toBe('assertive');
expect(itemEl.getAttribute('aria-atomic')).toBe('true');
expect(itemEl.getAttribute('aria-labelledby')).toBe('toast-title');
expect(itemEl.getAttribute('aria-describedby')).toBe('toast-description');
expect(itemEl.getAttribute('data-intent')).toBe('risk');
expect(itemEl.getAttribute('data-loading')).toBe('');
expect(actionEl.getAttribute('type')).toBe('button');
expect(actionEl.getAttribute('aria-label')).toBe('Undo save');
expect(closeEl.getAttribute('type')).toBe('button');
expect(closeEl.getAttribute('aria-label')).toBe('Close');
expect(statusEl.getAttribute('aria-hidden')).toBe('true');
result.item.onpointerenter();
expect(harness.handles.get('soma:toast:auto-dismiss:toast-item')?.cancel).toHaveBeenCalled();
result.item.onpointerleave();
expect(harness.schedule).toHaveBeenCalledTimes(2);
result.close.onclick({ currentTarget: closeEl } as never);
await tick();
expect(toaster.toasts.find((toast) => toast.id === id)?.dismissing).toBe(true);
toastState.current = toaster.toasts[0]!;
await tick();
expect(itemEl.getAttribute('data-state')).toBe('closed');
cleanup();
harness.dom.dispose();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.