Add feed provider coverage

active-uix
dev 5 months ago
parent c0221e0365
commit 3e1cd3148d

@ -123,9 +123,11 @@ Actualizacion 2026-05-15:
renderizado, selección/removal, link y remove button. renderizado, selección/removal, link y remove button.
- Cobertura nueva para Announce: alternancia polite/assertive, timers UIX, - Cobertura nueva para Announce: alternancia polite/assertive, timers UIX,
regiones ARIA declarativas y uso standalone. regiones ARIA declarativas y uso standalone.
- Cobertura nueva para Feed: attrs APG feed/article/title, navegación
PageUp/PageDown, threads anidados y sentinel IntersectionObserver.
- Validado por fase: - Validado por fase:
- `npm run check` -> 0 errores, 0 warnings. - `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 222 archivos, 2215 tests OK. - `npm run test` -> 223 archivos, 2218 tests OK.
- tests focales de Select/Popover/Toast/RangeCalendar -> 11 tests OK. - tests focales de Select/Popover/Toast/RangeCalendar -> 11 tests OK.
- tests focales Dialog/Drawer/Popover/Select -> 10 tests OK. - tests focales Dialog/Drawer/Popover/Select -> 10 tests OK.
- `command-provider.svelte.test.ts` -> 2 tests OK. - `command-provider.svelte.test.ts` -> 2 tests OK.
@ -176,6 +178,7 @@ Actualizacion 2026-05-15:
- `rating-group-provider.svelte.test.ts` -> 3 tests OK. - `rating-group-provider.svelte.test.ts` -> 3 tests OK.
- `tag-group-provider.svelte.test.ts` -> 3 tests OK. - `tag-group-provider.svelte.test.ts` -> 3 tests OK.
- `announce-provider.svelte.test.ts` -> 3 tests OK. - `announce-provider.svelte.test.ts` -> 3 tests OK.
- `feed-provider.svelte.test.ts` -> 3 tests OK.
- guardia `rg "from './.*provider\\.svelte'" src/uix/soma/components -g 'exports.ts'` - guardia `rg "from './.*provider\\.svelte'" src/uix/soma/components -g 'exports.ts'`
-> solo queda `select/components/select-provider.svelte`, que es wrapper -> solo queda `select/components/select-provider.svelte`, que es wrapper
publico, no runtime provider. publico, no runtime provider.

@ -555,6 +555,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
por DOM renderizado, selección/removal, link y remove button. por DOM renderizado, selección/removal, link y remove button.
- `announce/announce-provider.svelte.test.ts` — alternancia polite/assertive, - `announce/announce-provider.svelte.test.ts` — alternancia polite/assertive,
timers UIX, regiones ARIA declarativas y uso standalone. timers UIX, regiones ARIA declarativas y uso standalone.
- `feed/feed-provider.svelte.test.ts` — attrs APG feed/article/title,
navegación PageUp/PageDown, threads anidados y sentinel IntersectionObserver.
Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por
componentes de riesgo medio y familias menos centrales. `table-core`, componentes de riesgo medio y familias menos centrales. `table-core`,

@ -0,0 +1,292 @@
// @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 {
FeedArticleDescriptionProvider,
FeedArticleProvider,
FeedArticleTitleProvider,
FeedProvider,
FeedSentinelProvider,
FeedThreadProvider
} from './feed-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 soma = {
dom,
langs: {
ts: vi.fn(() => 'Feed')
},
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(FeedProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(FeedArticleProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(FeedThreadProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(FeedProvider, 'get').mockReturnValue(undefined);
vi.spyOn(FeedThreadProvider, 'get').mockReturnValue(undefined);
return { dom };
}
function feedOpts(root = document.createElement('section')) {
return {
id: state('feed-root'),
ref: state<HTMLElement | null>(root),
totalItems: state<number | undefined>(10),
busy: state(true),
ariaLabel: state<string | undefined>(undefined),
ariaLabelledby: state<string | undefined>(undefined),
onLoadMore: state<(() => void) | undefined>(undefined)
};
}
function keyEvent(key: string, currentTarget: HTMLElement, ctrlKey = false) {
return {
key,
ctrlKey,
currentTarget,
preventDefault: vi.fn()
};
}
describe('FeedProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('projects feed/article/title attrs and navigates articles by keyboard', async () => {
const { dom } = installSomaHarness();
const focusSpy = vi.spyOn(dom, 'focus');
const root = document.createElement('section');
const firstEl = document.createElement('article');
const secondEl = document.createElement('article');
const titleEl = document.createElement('h3');
const descriptionEl = document.createElement('p');
root.setAttribute('data-feed', '');
firstEl.setAttribute('data-feed-article', '');
secondEl.setAttribute('data-feed-article', '');
root.append(firstEl, secondEl);
firstEl.append(titleEl, descriptionEl);
document.body.appendChild(root);
const opts = feedOpts(root);
const onLoadMore = vi.fn();
opts.onLoadMore.current = onLoadMore;
const { result, cleanup } = withEffectRoot(() => {
const provider = FeedProvider.create(opts);
vi.spyOn(FeedProvider, 'require').mockReturnValue(provider);
const first = FeedArticleProvider.create({
id: state('feed-article-1'),
ref: state<HTMLElement | null>(firstEl),
posinset: state<number | undefined>(undefined)
});
vi.spyOn(FeedArticleProvider, 'require').mockReturnValue(first);
const title = FeedArticleTitleProvider.create({
id: state('feed-title-1'),
ref: state<HTMLElement | null>(titleEl),
level: state<number | undefined>(undefined)
});
const description = FeedArticleDescriptionProvider.create({
id: state('feed-description-1'),
ref: state<HTMLElement | null>(descriptionEl)
});
const second = FeedArticleProvider.create({
id: state('feed-article-2'),
ref: state<HTMLElement | null>(secondEl),
posinset: state<number | undefined>(undefined)
});
return { provider, first, second, title, description };
});
expect(result.provider.snippetProps).toEqual({ busy: true });
expect(result.title.resolvedLevel).toBe(3);
expect(result.first.titleId.current).toBe('feed-title-1');
expect(result.first.descriptionId.current).toBe('feed-description-1');
await tick();
expect(result.first.snippetProps).toEqual({ index: 0, total: 10 });
expect(result.second.snippetProps).toEqual({ index: 1, total: 10 });
expect(root.getAttribute('role')).toBe('feed');
expect(root.getAttribute('aria-label')).toBe('Feed');
expect(root.getAttribute('aria-busy')).toBe('true');
expect(firstEl.getAttribute('role')).toBe('article');
expect(firstEl.getAttribute('aria-posinset')).toBe('1');
expect(firstEl.getAttribute('aria-setsize')).toBe('10');
expect(firstEl.getAttribute('aria-labelledby')).toBe('feed-title-1');
expect(firstEl.getAttribute('aria-describedby')).toBe('feed-description-1');
expect(titleEl.getAttribute('role')).toBe('heading');
expect(titleEl.getAttribute('aria-level')).toBe('3');
expect(secondEl.getAttribute('aria-posinset')).toBe('2');
const next = keyEvent('PageDown', firstEl);
result.first.onkeydown(next as never);
expect(next.preventDefault).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenLastCalledWith(secondEl);
const end = keyEvent('PageDown', secondEl);
result.second.onkeydown(end as never);
expect(end.preventDefault).toHaveBeenCalledOnce();
expect(onLoadMore).toHaveBeenCalledOnce();
const home = keyEvent('Home', secondEl, true);
result.second.onkeydown(home as never);
expect(home.preventDefault).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenLastCalledWith(firstEl);
cleanup();
dom.dispose();
});
it('derives nested thread and article levels', async () => {
const { dom } = installSomaHarness();
const root = document.createElement('section');
const parentEl = document.createElement('article');
const parentTitleEl = document.createElement('h3');
const threadEl = document.createElement('div');
const nestedEl = document.createElement('article');
root.setAttribute('data-feed', '');
parentEl.setAttribute('data-feed-article', '');
threadEl.setAttribute('data-feed-thread', '');
nestedEl.setAttribute('data-feed-article', '');
root.appendChild(parentEl);
parentEl.append(parentTitleEl, threadEl);
threadEl.appendChild(nestedEl);
document.body.appendChild(root);
const opts = feedOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = FeedProvider.create(opts);
vi.spyOn(FeedProvider, 'require').mockReturnValue(provider);
const parent = FeedArticleProvider.create({
id: state('feed-parent'),
ref: state<HTMLElement | null>(parentEl),
posinset: state<number | undefined>(undefined)
});
vi.spyOn(FeedArticleProvider, 'require').mockReturnValue(parent);
const title = FeedArticleTitleProvider.create({
id: state('feed-parent-title'),
ref: state<HTMLElement | null>(parentTitleEl),
level: state<number | undefined>(undefined)
});
const thread = FeedThreadProvider.create({
id: state('feed-thread'),
ref: state<HTMLElement | null>(threadEl)
});
vi.mocked(FeedThreadProvider.get).mockReturnValue(thread);
const nested = FeedArticleProvider.create({
id: state('feed-nested'),
ref: state<HTMLElement | null>(nestedEl),
posinset: state<number | undefined>(undefined)
});
return { parent, title, thread, nested };
});
expect(result.parent.level).toBe(1);
expect(result.thread.level).toBe(2);
expect(result.nested.level).toBe(2);
expect(result.title.resolvedLevel).toBe(3);
await tick();
expect(threadEl.getAttribute('role')).toBe('feed');
expect(threadEl.getAttribute('data-level')).toBe('2');
expect(threadEl.getAttribute('aria-labelledby')).toBe('feed-parent-title');
expect(nestedEl.getAttribute('data-level')).toBe('2');
expect(nestedEl.getAttribute('aria-setsize')).toBe('-1');
cleanup();
dom.dispose();
});
it('observes sentinel intersections and falls back to feed onLoadMore', async () => {
const { dom } = installSomaHarness();
let observerCallback!: IntersectionObserverCallback;
const observerCleanup = vi.fn();
const observeIntersection = vi
.spyOn(dom, 'observeIntersection')
.mockImplementation((_target, callback) => {
observerCallback = callback;
return observerCleanup;
});
const root = document.createElement('section');
const sentinelEl = document.createElement('div');
root.appendChild(sentinelEl);
document.body.appendChild(root);
const opts = feedOpts(root);
const onLoadMore = vi.fn();
opts.onLoadMore.current = onLoadMore;
const { result, cleanup } = withEffectRoot(() => {
const provider = FeedProvider.create(opts);
vi.mocked(FeedProvider.get).mockReturnValue(provider);
const sentinel = FeedSentinelProvider.create({
id: state('feed-sentinel'),
ref: state<HTMLElement | null>(sentinelEl),
rootMargin: state('100px'),
threshold: state(0.25),
root: state<Element | Document | null>(document),
onIntersect: state<(() => void) | undefined>(undefined),
disabled: state(false)
});
return { provider, sentinel };
});
await tick();
expect(result.sentinel.feed).toBe(result.provider);
expect(observeIntersection).toHaveBeenCalledWith(
sentinelEl,
expect.any(Function),
expect.objectContaining({
root: null,
rootMargin: '100px',
threshold: 0.25
})
);
observerCallback(
[{ target: sentinelEl, isIntersecting: true } as unknown as IntersectionObserverEntry],
{
disconnect: vi.fn(),
observe: vi.fn(),
root: null,
rootMargin: '',
takeRecords: vi.fn(() => []),
thresholds: [],
unobserve: vi.fn()
} as IntersectionObserver
);
expect(result.sentinel.intersecting).toBe(true);
expect(onLoadMore).toHaveBeenCalledOnce();
await tick();
expect(sentinelEl.getAttribute('aria-hidden')).toBe('true');
expect(sentinelEl.getAttribute('data-intersecting')).toBe('');
cleanup();
expect(observerCleanup).toHaveBeenCalledOnce();
dom.dispose();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.