parent
c0221e0365
commit
3e1cd3148d
@ -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…
Reference in new issue