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.
293 lines
9.8 KiB
293 lines
9.8 KiB
|
5 months ago
|
// @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();
|
||
|
|
});
|
||
|
|
});
|