// @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(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) => 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(root), totalItems: state(10), busy: state(true), ariaLabel: state(undefined), ariaLabelledby: state(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(firstEl), posinset: state(undefined) }); vi.spyOn(FeedArticleProvider, 'require').mockReturnValue(first); const title = FeedArticleTitleProvider.create({ id: state('feed-title-1'), ref: state(titleEl), level: state(undefined) }); const description = FeedArticleDescriptionProvider.create({ id: state('feed-description-1'), ref: state(descriptionEl) }); const second = FeedArticleProvider.create({ id: state('feed-article-2'), ref: state(secondEl), posinset: state(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(parentEl), posinset: state(undefined) }); vi.spyOn(FeedArticleProvider, 'require').mockReturnValue(parent); const title = FeedArticleTitleProvider.create({ id: state('feed-parent-title'), ref: state(parentTitleEl), level: state(undefined) }); const thread = FeedThreadProvider.create({ id: state('feed-thread'), ref: state(threadEl) }); vi.mocked(FeedThreadProvider.get).mockReturnValue(thread); const nested = FeedArticleProvider.create({ id: state('feed-nested'), ref: state(nestedEl), posinset: state(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(sentinelEl), rootMargin: state('100px'), threshold: state(0.25), root: state(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(); }); });