You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/feed/feed-provider.svelte.test.ts

293 lines
9.8 KiB

// @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();
});
});

Powered by TurnKey Linux.