test: expand hook and utility runtime coverage

This commit is contained in:
plebeius
2026-03-08 15:46:05 +08:00
parent 36616990d4
commit dc5d23d8d9
9 changed files with 1426 additions and 0 deletions
@@ -0,0 +1,246 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CatalogFooterFirstRow,
PageFooterDesktop,
PageFooterMobile,
StyleOnlyFooterFirstRow,
ThreadFooterFirstRow,
ThreadFooterMobile,
ThreadFooterStyleRow,
} from '../footer';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
directoryEntry: { features: {} } as { features?: Record<string, unknown> } | undefined,
linkCount: 2,
openReplyModalEmptyMock: vi.fn(),
pageNumber: 4 as number | undefined,
post: { replyCount: 7 } as { replyCount?: number } | undefined,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
useComment: () => testState.post,
}));
vi.mock('../../boards-bar', () => ({
default: () => createElement('div', { 'data-testid': 'boards-bar' }, 'boards-bar'),
}));
vi.mock('../../site-legal-meta', () => ({
default: ({ order }: { order?: string }) => createElement('div', { 'data-order': order, 'data-testid': 'site-legal-meta' }, `site-legal-meta:${order}`),
}));
vi.mock('../../style-selector/style-selector', () => ({
default: () => createElement('div', { 'data-testid': 'style-selector' }, 'style-selector'),
}));
vi.mock('../../board-buttons/board-buttons', () => ({
AutoButton: () => createElement('button', { type: 'button' }, 'auto-button'),
CatalogButton: ({
address,
isInAllView,
isInModView,
isInSubscriptionsView,
}: {
address?: string;
isInAllView?: boolean;
isInModView?: boolean;
isInSubscriptionsView?: boolean;
}) => createElement('button', { 'data-testid': 'catalog-button', type: 'button' }, `${address}|${isInAllView}|${isInSubscriptionsView}|${isInModView}`),
PostPageStats: () => createElement('div', { 'data-testid': 'post-page-stats' }, 'post-page-stats'),
RefreshButton: () => createElement('button', { type: 'button' }, 'refresh-button'),
ReturnButton: ({
address,
isInAllView,
isInModView,
isInSubscriptionsView,
}: {
address?: string;
isInAllView?: boolean;
isInModView?: boolean;
isInSubscriptionsView?: boolean;
}) => createElement('button', { 'data-testid': 'return-button', type: 'button' }, `${address}|${isInAllView}|${isInSubscriptionsView}|${isInModView}`),
TopButton: () => createElement('button', { type: 'button' }, 'top-button'),
UpdateButton: () => createElement('button', { type: 'button' }, 'update-button'),
}));
vi.mock('../../../stores/use-reply-modal-store', () => ({
default: () => ({
openReplyModalEmpty: testState.openReplyModalEmptyMock,
}),
}));
vi.mock('../../../hooks/use-count-links-in-replies', () => ({
default: () => testState.linkCount,
}));
vi.mock('../../../hooks/use-post-page-number', () => ({
usePostPageNumber: () => testState.pageNumber,
}));
vi.mock('../../../hooks/use-directories', () => ({
useDirectoryByAddress: () => testState.directoryEntry,
}));
let container: HTMLDivElement;
let root: Root;
const renderWithRouter = async (element: React.ReactNode, initialEntry = '/mu/thread/post-1') => {
await act(async () => {
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, element));
});
};
describe('footer', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.directoryEntry = { features: {} };
testState.linkCount = 2;
testState.openReplyModalEmptyMock.mockReset();
testState.pageNumber = 4;
testState.post = { replyCount: 7 };
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders the desktop footer shell with optional style row, boards bar, and legal metadata', async () => {
await renderWithRouter(
createElement(PageFooterDesktop, {
firstRow: createElement('div', { 'data-testid': 'first-row' }, 'first-row'),
styleRow: createElement('div', { 'data-testid': 'style-row' }, 'style-row'),
}),
);
expect(container.querySelector('[data-testid="first-row"]')?.textContent).toBe('first-row');
expect(container.querySelector('[data-testid="style-row"]')?.textContent).toBe('style-row');
expect(container.querySelector('[data-testid="boards-bar"]')?.textContent).toBe('boards-bar');
expect(container.querySelector('[data-testid="site-legal-meta"]')?.getAttribute('data-order')).toBe('license-first');
});
it('renders style-only, catalog, and mobile footer wrappers with shared controls', async () => {
await renderWithRouter(
createElement(
React.Fragment,
{},
createElement(StyleOnlyFooterFirstRow),
createElement(CatalogFooterFirstRow, {
isInAllView: true,
subplebbitAddress: 'music-posting.eth',
}),
createElement(ThreadFooterStyleRow),
createElement(PageFooterMobile, {
children: createElement('div', { 'data-testid': 'mobile-child' }, 'mobile-child'),
}),
),
'/all/catalog',
);
expect(container.textContent).toContain('style:');
expect(container.querySelectorAll('[data-testid="style-selector"]')).toHaveLength(3);
expect(container.querySelector('[data-testid="return-button"]')?.textContent).toBe('music-posting.eth|true|false|false');
expect(container.querySelector('[data-testid="catalog-button"]')?.textContent).toBe('music-posting.eth|true|false|false');
expect(container.textContent).toContain('refresh-button');
expect(container.querySelector('[data-testid="mobile-child"]')?.textContent).toBe('mobile-child');
});
it('opens the reply modal from the desktop thread footer unless the thread is closed', async () => {
await renderWithRouter(
createElement(ThreadFooterFirstRow, {
postCid: 'post-cid',
subplebbitAddress: 'music-posting.eth',
threadNumber: 42,
}),
'/all/thread/post-cid',
);
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.getAttribute('aria-label') === 'post_a_reply');
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.openReplyModalEmptyMock).toHaveBeenCalledWith('post-cid', 42, 'music-posting.eth');
expect(container.querySelector('[data-testid="post-page-stats"]')?.textContent).toBe('post-page-stats');
testState.openReplyModalEmptyMock.mockReset();
await renderWithRouter(
createElement(ThreadFooterFirstRow, {
isThreadClosed: true,
postCid: 'post-cid',
subplebbitAddress: 'music-posting.eth',
threadNumber: 42,
}),
'/all/thread/post-cid',
);
const closedButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.getAttribute('aria-label') === 'post_a_reply');
expect(closedButton?.hasAttribute('disabled')).toBe(true);
await act(async () => {
closedButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.openReplyModalEmptyMock).not.toHaveBeenCalled();
});
it('renders mobile thread stats using directory media requirements and opens replies when available', async () => {
testState.directoryEntry = { features: { requirePostLinkIsMedia: true } };
await renderWithRouter(
createElement(ThreadFooterMobile, {
postCid: 'post-cid',
subplebbitAddress: 'music-posting.eth',
threadNumber: 55,
}),
);
expect(container.textContent).toContain('Replies: 7 / Images: 2 / pagination.pageLabel: 4');
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'post_a_reply');
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.openReplyModalEmptyMock).toHaveBeenCalledWith('post-cid', 55, 'music-posting.eth');
});
it('falls back to link stats and unknown counts when thread data is unavailable or closed on mobile', async () => {
testState.directoryEntry = { features: {} };
testState.linkCount = undefined as unknown as number;
testState.pageNumber = undefined;
testState.post = undefined;
await renderWithRouter(
createElement(ThreadFooterMobile, {
isThreadClosed: true,
postCid: 'post-cid',
subplebbitAddress: 'music-posting.eth',
threadNumber: 55,
}),
);
expect(container.textContent).toContain('Replies: ? / Links: ? / pagination.pageLabel: ?');
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'post_a_reply');
expect(button?.hasAttribute('disabled')).toBe(true);
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.openReplyModalEmptyMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,173 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CommentMediaInfo } from '../../lib/utils/media-utils';
import { useCommentMediaInfo } from '../use-comment-media-info';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
fetchWebpageThumbnailIfNeededMock: vi.fn<(mediaInfo: CommentMediaInfo) => Promise<CommentMediaInfo>>(),
getCommentMediaInfoMock: vi.fn<(link: string, thumbnailUrl: string, linkWidth: number, linkHeight: number) => CommentMediaInfo | undefined>(),
imageHeight: 360,
imageWidth: 640,
mediaInfo: undefined as CommentMediaInfo | undefined,
params: {} as Record<string, string>,
pathname: '/all',
thumbnailMediaInfo: undefined as CommentMediaInfo | undefined,
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useLocation: () => ({ pathname: testState.pathname }),
useParams: () => testState.params,
};
});
vi.mock('../../lib/utils/media-utils', () => ({
fetchWebpageThumbnailIfNeeded: (mediaInfo: CommentMediaInfo) => testState.fetchWebpageThumbnailIfNeededMock(mediaInfo),
getCommentMediaInfo: (link: string, thumbnailUrl: string, linkWidth: number, linkHeight: number) =>
testState.getCommentMediaInfoMock(link, thumbnailUrl, linkWidth, linkHeight),
}));
let latestValue: CommentMediaInfo | undefined;
let container: HTMLDivElement;
let root: Root;
let originalImage: typeof globalThis.Image;
class MockImage {
height = testState.imageHeight;
onerror: ((this: GlobalEventHandlers, ev: Event | string) => any) | null = null;
onload: ((this: GlobalEventHandlers, ev: Event) => any) | null = null;
width = testState.imageWidth;
set src(_value: string) {
setTimeout(() => {
this.onload?.call(this as never, new Event('load'));
}, 0);
}
}
const HookHarness = ({ link, linkHeight, linkWidth, thumbnailUrl }: { link: string; linkHeight: number; linkWidth: number; thumbnailUrl: string }) => {
latestValue = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
return null;
};
const flushEffects = async (count = 5) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
};
const renderHook = async (props = { link: 'https://example.com', linkHeight: 360, linkWidth: 640, thumbnailUrl: '' }) => {
await act(async () => {
root.render(createElement(HookHarness, props));
});
await flushEffects();
};
describe('useCommentMediaInfo', () => {
beforeEach(() => {
vi.clearAllMocks();
latestValue = undefined;
testState.imageHeight = 360;
testState.imageWidth = 640;
testState.mediaInfo = undefined;
testState.params = {};
testState.pathname = '/all';
testState.thumbnailMediaInfo = undefined;
testState.getCommentMediaInfoMock.mockImplementation(() => testState.mediaInfo);
testState.fetchWebpageThumbnailIfNeededMock.mockImplementation(async () => testState.thumbnailMediaInfo ?? (testState.mediaInfo as CommentMediaInfo));
originalImage = globalThis.Image;
globalThis.Image = MockImage as never;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
globalThis.Image = originalImage;
});
it('returns the current media info without fetching thumbnails outside post and pending views', async () => {
testState.mediaInfo = {
thumbnail: 'https://cdn.example.com/thumb.jpg',
type: 'image',
url: 'https://cdn.example.com/image.jpg',
};
await renderHook({
link: 'https://cdn.example.com/image.jpg',
linkHeight: 360,
linkWidth: 640,
thumbnailUrl: 'https://cdn.example.com/thumb.jpg',
});
expect(latestValue).toEqual(testState.mediaInfo);
expect(testState.fetchWebpageThumbnailIfNeededMock).not.toHaveBeenCalled();
});
it('fetches webpage thumbnails on thread routes and stores the loaded dimensions', async () => {
testState.pathname = '/biz/thread/post-1';
testState.params = {
boardIdentifier: 'biz',
commentCid: 'post-1',
};
testState.mediaInfo = {
type: 'webpage',
url: 'https://example.com',
};
testState.thumbnailMediaInfo = {
...testState.mediaInfo,
thumbnail: 'https://cdn.example.com/thumb.jpg',
};
await renderHook({
link: 'https://example.com',
linkHeight: 360,
linkWidth: 640,
thumbnailUrl: '',
});
expect(testState.fetchWebpageThumbnailIfNeededMock).toHaveBeenCalledWith(testState.mediaInfo);
expect(latestValue).toMatchObject({
thumbnailHeight: 360,
thumbnailWidth: 640,
type: 'webpage',
url: 'https://example.com',
});
});
it('skips webpage thumbnail fetching when the route is pending but a thumbnail already exists', async () => {
testState.pathname = '/pending/42';
testState.params = {
accountCommentIndex: '42',
};
testState.mediaInfo = {
thumbnail: 'https://cdn.example.com/existing-thumb.jpg',
type: 'webpage',
url: 'https://example.com/already-thumbnailed',
};
await renderHook({
link: 'https://example.com/already-thumbnailed',
linkHeight: 360,
linkWidth: 640,
thumbnailUrl: 'https://cdn.example.com/existing-thumb.jpg',
});
expect(latestValue).toEqual(testState.mediaInfo);
expect(testState.fetchWebpageThumbnailIfNeededMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,174 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useIsSubplebbitOffline from '../use-is-subplebbit-offline';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
initializeMock: vi.fn(),
loadingTimestamps: [0] as number[],
requestedAddresses: undefined as string[] | undefined,
setOfflineStateMock: vi.fn(),
subplebbitOfflineState: {} as Record<string, { initialLoad: boolean; state?: string; updatedAt?: number; updatingState?: string }>,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'posts_last_synced_info') {
return `posts_last_synced_info:${options?.time}`;
}
return key;
},
}),
}));
vi.mock('../../stores/use-subplebbit-offline-store', () => ({
default: () => ({
initializesubplebbitOfflineState: testState.initializeMock,
setSubplebbitOfflineState: testState.setOfflineStateMock,
subplebbitOfflineState: testState.subplebbitOfflineState,
}),
}));
vi.mock('../../stores/use-subplebbits-loading-start-timestamps-store', () => ({
default: (addresses?: string[]) => {
testState.requestedAddresses = addresses;
return testState.loadingTimestamps;
},
}));
vi.mock('../../lib/utils/time-utils', () => ({
getFormattedTimeAgo: (timestamp: number) => `ago:${timestamp}`,
}));
let latestValue: ReturnType<typeof useIsSubplebbitOffline>;
let container: HTMLDivElement;
let root: Root;
const HookHarness = ({ subplebbit }: { subplebbit?: { address?: string; state?: string; updatedAt?: number; updatingState?: string } }) => {
latestValue = useIsSubplebbitOffline(subplebbit as never);
return null;
};
const flushEffects = async (count = 3) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
};
const renderHook = async (subplebbit?: { address?: string; state?: string; updatedAt?: number; updatingState?: string }) => {
await act(async () => {
root.render(createElement(HookHarness, { subplebbit }));
});
await flushEffects();
};
describe('useIsSubplebbitOffline', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:10Z'));
latestValue = {
isOffline: false,
isOnlineStatusLoading: false,
offlineIconClass: '',
offlineTitle: '',
};
testState.loadingTimestamps = [1_704_067_200];
testState.requestedAddresses = undefined;
testState.subplebbitOfflineState = {};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
it('initializes unseen boards and reports a loading state during the first sync window', async () => {
await renderHook({ address: 'music.eth', state: 'updating', updatingState: 'fetching' });
expect(testState.requestedAddresses).toEqual(['music.eth']);
expect(testState.initializeMock).toHaveBeenCalledWith('music.eth');
expect(testState.setOfflineStateMock).toHaveBeenCalledWith('music.eth', {
state: 'updating',
updatedAt: undefined,
updatingState: 'fetching',
});
expect(latestValue).toEqual({
isOffline: false,
isOnlineStatusLoading: true,
offlineIconClass: 'yellowOfflineIcon',
offlineTitle: 'downloading board...',
});
});
it('reports boards with stale updates as offline and includes the last synced time', async () => {
const staleUpdatedAt = 1_704_052_000;
testState.subplebbitOfflineState = {
'music.eth': {
initialLoad: false,
updatedAt: staleUpdatedAt,
},
};
testState.loadingTimestamps = [1_704_067_000];
await renderHook({ address: 'music.eth', state: 'stopped', updatedAt: staleUpdatedAt });
expect(testState.initializeMock).not.toHaveBeenCalled();
expect(latestValue).toEqual({
isOffline: true,
isOnlineStatusLoading: false,
offlineIconClass: 'redOfflineIcon',
offlineTitle: `posts_last_synced_info:ago:${staleUpdatedAt}`,
});
});
it('marks boards without an update timestamp as offline once the loading timeout has elapsed', async () => {
testState.subplebbitOfflineState = {
'music.eth': {
initialLoad: false,
},
};
testState.loadingTimestamps = [1_704_067_100];
await renderHook({ address: 'music.eth' });
expect(latestValue).toEqual({
isOffline: true,
isOnlineStatusLoading: false,
offlineIconClass: 'redOfflineIcon',
offlineTitle: 'subplebbit_offline_info',
});
});
it('treats recently updated boards as online', async () => {
const freshUpdatedAt = 1_704_067_205;
testState.subplebbitOfflineState = {
'music.eth': {
initialLoad: false,
updatedAt: freshUpdatedAt,
},
};
await renderHook({ address: 'music.eth', state: 'started', updatedAt: freshUpdatedAt });
expect(latestValue).toEqual({
isOffline: false,
isOnlineStatusLoading: false,
offlineIconClass: '',
offlineTitle: false,
});
});
});
@@ -0,0 +1,124 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { usePostPageNumber } from '../use-post-page-number';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
community: { address: 'music.eth', features: {} },
feedsOptions: {} as Record<string, unknown>,
loadedFeeds: {} as Record<string, unknown>,
preloadFeed: undefined as Array<{ cid: string }> | undefined,
preloadOptions: undefined as Record<string, unknown> | undefined,
sizes: {
guiPostsPerPage: 2,
paginationFeedPostsPerPage: 20,
},
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
useFeed: (options: Record<string, unknown> | undefined) => {
testState.preloadOptions = options;
return { feed: testState.preloadFeed };
},
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/feeds', () => ({
default: (selector: (state: { feedsOptions: Record<string, unknown>; loadedFeeds: Record<string, unknown> }) => unknown) =>
selector({
feedsOptions: testState.feedsOptions,
loadedFeeds: testState.loadedFeeds,
}),
}));
vi.mock('../use-directories', () => ({
useDirectoryByAddress: () => testState.community,
}));
vi.mock('../use-board-feed-page-size', () => ({
useBoardFeedPageSize: () => testState.sizes,
}));
let latestValue: number | undefined;
let container: HTMLDivElement;
let root: Root;
const HookHarness = ({ enabled = true, postCid, subplebbitAddress }: { enabled?: boolean; postCid?: string; subplebbitAddress?: string }) => {
latestValue = usePostPageNumber({ enabled, postCid, subplebbitAddress });
return null;
};
const renderHook = (props: { enabled?: boolean; postCid?: string; subplebbitAddress?: string }) => {
act(() => {
root.render(createElement(HookHarness, props));
});
return latestValue;
};
describe('usePostPageNumber', () => {
beforeEach(() => {
latestValue = undefined;
testState.community = { address: 'music.eth', features: {} };
testState.feedsOptions = {};
testState.loadedFeeds = {};
testState.preloadFeed = undefined;
testState.preloadOptions = undefined;
testState.sizes = {
guiPostsPerPage: 2,
paginationFeedPostsPerPage: 20,
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('returns the cached board page when the post already exists in loaded feeds', () => {
testState.feedsOptions = {
boardFeed: {
sortType: 'active',
subplebbitAddresses: ['music.eth'],
},
};
testState.loadedFeeds = {
boardFeed: [{ cid: 'post-1' }, { cid: 'post-2' }, { cid: 'post-3' }],
};
expect(renderHook({ postCid: 'post-3', subplebbitAddress: 'music.eth' })).toBe(2);
expect(testState.preloadOptions).toEqual({
postsPerPage: 20,
sortType: 'active',
subplebbitAddresses: ['music.eth'],
});
});
it('falls back to the preloaded feed when cached feeds do not contain the post yet', () => {
testState.preloadFeed = [{ cid: 'post-1' }, { cid: 'post-2' }, { cid: 'post-3' }, { cid: 'post-4' }];
expect(renderHook({ postCid: 'post-4', subplebbitAddress: 'music.eth' })).toBe(2);
expect(testState.preloadOptions).toEqual({
postsPerPage: 20,
sortType: 'active',
subplebbitAddresses: ['music.eth'],
});
});
it('skips resolution entirely when the hook is disabled or required inputs are missing', () => {
testState.preloadFeed = [{ cid: 'post-1' }];
expect(renderHook({ enabled: false, postCid: 'post-1', subplebbitAddress: 'music.eth' })).toBeUndefined();
expect(testState.preloadOptions).toBeUndefined();
expect(renderHook({ enabled: true, postCid: undefined, subplebbitAddress: 'music.eth' })).toBeUndefined();
expect(testState.preloadOptions).toBeUndefined();
});
});
@@ -0,0 +1,152 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { VirtuosoHandle } from 'react-virtuoso';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useScrollToReply from '../use-scroll-to-reply';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const testState = vi.hoisted(() => ({
loadMoreMock: vi.fn(),
scrollToIndexMock: vi.fn(),
}));
let container: HTMLDivElement;
let root: Root;
const HookHarness = ({
enabled = true,
hasMore,
replies,
targetReplyCid,
}: {
enabled?: boolean;
hasMore: boolean;
replies: Array<{ cid?: string | null }>;
targetReplyCid?: string;
}) => {
useScrollToReply({
enabled,
hasMore,
loadMore: testState.loadMoreMock,
replies,
targetReplyCid,
virtuosoRef: {
current: {
scrollToIndex: testState.scrollToIndexMock,
} as unknown as VirtuosoHandle,
} as React.RefObject<VirtuosoHandle | null>,
});
return null;
};
const renderHook = async (props: { enabled?: boolean; hasMore: boolean; replies: Array<{ cid?: string | null }>; targetReplyCid?: string }) => {
await act(async () => {
root.render(createElement(HookHarness, props));
});
};
describe('useScrollToReply', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.querySelectorAll('[data-cid]').forEach((node) => node.remove());
vi.useRealTimers();
});
it('scrolls directly to the target reply when it is already loaded', async () => {
await renderHook({
hasMore: true,
replies: [{ cid: 'reply-1' }, { cid: 'reply-2' }],
targetReplyCid: 'reply-2',
});
act(() => {
vi.advanceTimersByTime(350);
});
expect(testState.scrollToIndexMock).toHaveBeenCalledWith({
align: 'center',
behavior: 'smooth',
index: 1,
});
expect(testState.loadMoreMock).not.toHaveBeenCalled();
});
it('scrolls to the latest loaded reply and schedules loadMore while searching', async () => {
await renderHook({
hasMore: true,
replies: [{ cid: 'reply-1' }, { cid: 'reply-2' }],
targetReplyCid: 'reply-3',
});
act(() => {
vi.advanceTimersByTime(350);
});
expect(testState.scrollToIndexMock).toHaveBeenCalledWith({
align: 'end',
behavior: 'smooth',
index: 1,
});
act(() => {
vi.advanceTimersByTime(300);
});
expect(testState.loadMoreMock).toHaveBeenCalledTimes(1);
});
it('uses DOM scrolling once all replies are loaded and the target element exists', async () => {
const replyElement = document.createElement('div');
const scrollIntoViewMock = vi.fn();
replyElement.dataset.cid = 'reply-3';
replyElement.scrollIntoView = scrollIntoViewMock;
document.body.appendChild(replyElement);
await renderHook({
hasMore: false,
replies: [{ cid: 'reply-1' }],
targetReplyCid: 'reply-3',
});
act(() => {
vi.advanceTimersByTime(350);
});
expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
});
expect(testState.scrollToIndexMock).not.toHaveBeenCalled();
});
it('warns once the target reply cannot be found after all pages are loaded', async () => {
await renderHook({
hasMore: false,
replies: [{ cid: 'reply-1' }],
targetReplyCid: 'reply-404',
});
act(() => {
vi.advanceTimersByTime(350);
});
expect(warnSpy).toHaveBeenCalledWith('[scroll-to-reply] Could not find reply with CID "reply-404" in the feed.');
expect(testState.loadMoreMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { alertChallengeVerificationFailed, getPublicationPreview, getPublicationType, getVotePreview } from '../challenge-utils';
const alertMock = vi.fn();
const originalAlert = globalThis.alert;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
describe('challenge-utils', () => {
beforeEach(() => {
vi.clearAllMocks();
globalThis.alert = alertMock;
});
afterEach(() => {
globalThis.alert = originalAlert;
});
it('alerts with merged object challenge errors, reason, and resolved board path', () => {
alertChallengeVerificationFailed(
{
challengeErrors: {
captcha: 'invalid captcha',
ignored: 42,
},
challengeSuccess: false,
reason: 'try again later',
} as never,
{ subplebbitAddress: 'business-and-finance.bso' },
);
expect(warnSpy).toHaveBeenCalledWith(
'Challenge Verification Failed:',
expect.objectContaining({ challengeSuccess: false }),
'Publication:',
expect.objectContaining({ subplebbitAddress: 'business-and-finance.bso' }),
);
expect(alertMock).toHaveBeenCalledWith('Error from /biz/: invalid captcha try again later');
});
it('alerts with joined array challenge errors and falls back to the raw board address when unmapped', () => {
alertChallengeVerificationFailed(
{
challengeErrors: ['first error', 'second error'],
challengeSuccess: false,
} as never,
{ subplebbitAddress: 'unknown-board.eth' },
);
expect(alertMock).toHaveBeenCalledWith('Error from unknown-board.eth: first error second error');
});
it('warns about invalid challenge error payloads and falls back to an unknown error', () => {
alertChallengeVerificationFailed(
{
challengeErrors: 'bad-shape',
challengeSuccess: false,
} as never,
{},
);
expect(warnSpy).toHaveBeenCalledWith('challengeVerification.challengeErrors is not an object or array:', 'bad-shape');
expect(alertMock).toHaveBeenCalledWith('Error from unknown board: unknown error');
});
it('logs successful challenge verification instead of alerting', () => {
alertChallengeVerificationFailed({ challengeSuccess: true } as never, { subplebbitAddress: 'business-and-finance.bso' });
expect(logSpy).toHaveBeenCalledWith('Challenge verification succeeded:', expect.objectContaining({ challengeSuccess: true }));
expect(alertMock).not.toHaveBeenCalled();
});
it('classifies publication types and vote previews', () => {
expect(getPublicationType(undefined)).toBeUndefined();
expect(getPublicationType({ vote: 1 })).toBe('vote');
expect(getPublicationType({ parentCid: 'reply-parent' })).toBe('reply');
expect(getPublicationType({ commentCid: 'comment-cid' })).toBe('edit');
expect(getPublicationType({ title: 'new thread' })).toBe('post');
expect(getVotePreview(undefined)).toBe('');
expect(getVotePreview({ vote: 1 })).toBe(' +1');
expect(getVotePreview({ vote: -1 })).toBe(' -1');
});
it('builds publication previews from title, content, links, and truncation rules', () => {
expect(getPublicationPreview(undefined)).toBe('');
expect(getPublicationPreview({ link: 'https://example.com/only-link' })).toBe('https://example.com/only-link');
expect(getPublicationPreview({ title: 'Announcement', content: 'Now live' })).toBe('Announcement: Now live');
expect(
getPublicationPreview({
content: 'a'.repeat(80),
}),
).toBe(`${'a'.repeat(50)}...`);
});
});
+284
View File
@@ -0,0 +1,284 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const testState = vi.hoisted(() => ({
cachedThumbnails: new Map<string, string>(),
canEmbedHosts: new Set<string>(),
capacitorHttpGetMock: vi.fn(),
consoleErrorMock: vi.fn(),
fetchMock: vi.fn(),
isNativePlatform: false,
localForageGetItemMock: vi.fn(),
localForageSetItemMock: vi.fn(),
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({
default: {
createInstance: () => ({
getItem: (url: string) => testState.localForageGetItemMock(url),
setItem: (url: string, thumbnail: string) => testState.localForageSetItemMock(url, thumbnail),
}),
},
}));
vi.mock('../../../components/embed', () => ({
canEmbed: (url: URL) => testState.canEmbedHosts.has(url.hostname),
}));
vi.mock('@capacitor/core', () => ({
Capacitor: {
isNativePlatform: () => testState.isNativePlatform,
},
CapacitorHttp: {
get: (options: unknown) => testState.capacitorHttpGetMock(options),
},
}));
import { fetchWebpageThumbnailIfNeeded, getCommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getLinkMediaInfo, getMediaDimensions } from '../media-utils';
const clearMemoizedCache = (fn: unknown) => {
const memoized = fn as { clear?: () => void };
memoized.clear?.();
};
const createFetchResponse = (html: string, ok = true) => {
let sent = false;
return {
body: {
getReader: () => ({
read: async () => {
if (sent) {
return { done: true, value: undefined };
}
sent = true;
return {
done: false,
value: new TextEncoder().encode(html),
};
},
}),
},
ok,
};
};
describe('media-utils', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
testState.cachedThumbnails = new Map<string, string>();
testState.canEmbedHosts = new Set<string>();
testState.isNativePlatform = false;
testState.localForageGetItemMock.mockImplementation(async (url: string) => testState.cachedThumbnails.get(url) ?? null);
testState.localForageSetItemMock.mockImplementation(async (url: string, thumbnail: string) => {
testState.cachedThumbnails.set(url, thumbnail);
});
testState.fetchMock.mockReset();
testState.capacitorHttpGetMock.mockReset();
vi.stubGlobal('fetch', testState.fetchMock);
clearMemoizedCache(getHasThumbnail);
clearMemoizedCache(getLinkMediaInfo);
clearMemoizedCache(getMediaDimensions);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(testState.consoleErrorMock);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
vi.unstubAllGlobals();
});
it('maps media types to translated labels', () => {
const t = (key: string) => `translated:${key}`;
expect(getDisplayMediaInfoType('image', t)).toBe('translated:image');
expect(getDisplayMediaInfoType('gif', t)).toBe('translated:gif');
expect(getDisplayMediaInfoType('animated gif', t)).toBe('translated:animated_gif');
expect(getDisplayMediaInfoType('iframe', t)).toBe('translated:iframe');
expect(getDisplayMediaInfoType('video', t)).toBe('translated:video');
expect(getDisplayMediaInfoType('audio', t)).toBe('translated:audio');
expect(getDisplayMediaInfoType('unknown', t)).toBe('translated:webpage');
});
it('recognizes which media types expose thumbnails', () => {
expect(getHasThumbnail(undefined, 'https://example.com/file.png')).toBe(false);
expect(getHasThumbnail({ type: 'image', url: 'https://example.com/file.png' }, 'https://example.com/file.png')).toBe(true);
expect(getHasThumbnail({ type: 'video', url: 'https://example.com/file.mp4' }, 'https://example.com/file.mp4')).toBe(true);
expect(getHasThumbnail({ type: 'audio', url: 'https://example.com/file.mp3' }, 'https://example.com/file.mp3')).toBe(true);
expect(getHasThumbnail({ type: 'gif', url: 'https://example.com/file.gif' }, 'https://example.com/file.gif')).toBe(true);
expect(getHasThumbnail({ thumbnail: 'https://example.com/thumb.png', type: 'webpage', url: 'https://example.com' }, 'https://example.com')).toBe(true);
expect(
getHasThumbnail(
{ patternThumbnailUrl: 'https://img.youtube.com/vi/abc/0.jpg', type: 'iframe', url: 'https://www.youtube.com/watch?v=abc' },
'https://www.youtube.com/watch?v=abc',
),
).toBe(true);
expect(getHasThumbnail({ type: 'iframe', url: 'https://example.com/embed' }, 'https://example.com/embed')).toBe(false);
});
it('classifies direct media, embeds, imgbb pages, and unknown links', () => {
testState.canEmbedHosts = new Set(['www.youtube.com', 'streamable.com']);
expect(getLinkMediaInfo('not-a-url')).toBeUndefined();
expect(getLinkMediaInfo('https://example.com/_next/image?url=%2Fposter.png')).toMatchObject({ type: 'image' });
expect(getLinkMediaInfo('https://ibb.co/abc123')).toEqual({
thumbnail: 'https://i.ibb.co/abc123/thumbnail.jpg',
type: 'webpage',
url: 'https://ibb.co/abc123',
});
expect(getLinkMediaInfo('https://example.com/file.gif')).toMatchObject({ type: 'gif' });
expect(getLinkMediaInfo('https://example.com/file.png')).toMatchObject({ type: 'image' });
expect(getLinkMediaInfo('https://example.com/file.mp4')).toMatchObject({ type: 'video' });
expect(getLinkMediaInfo('https://example.com/file.mp3')).toMatchObject({ type: 'audio' });
expect(getLinkMediaInfo('https://example.com/path')).toMatchObject({ type: 'webpage' });
expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
});
expect(getLinkMediaInfo('https://streamable.com/clip123')).toEqual({
patternThumbnailUrl: 'https://cdn-cf-east.streamable.com/image/clip123.jpg',
type: 'iframe',
url: 'https://streamable.com/clip123',
});
expect(getLinkMediaInfo('https://yt.example/watch?v=yt123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/yt123/0.jpg',
type: 'iframe',
url: 'https://yt.example/watch?v=yt123',
});
});
it('builds comment media info and strips thumbnails for blacklisted domains', () => {
testState.canEmbedHosts = new Set(['www.youtube.com']);
expect(getCommentMediaInfo('', '', 0, 0)).toBeUndefined();
expect(getCommentMediaInfo('https://example.com/file.png', 'https://example.com/thumb.png', 320, 240)).toEqual({
linkHeight: 240,
linkWidth: 320,
thumbnail: 'https://example.com/thumb.png',
type: 'image',
url: 'https://example.com/file.png',
});
expect(getCommentMediaInfo('https://x.com/post/123', 'https://example.com/thumb.png', 100, 50)).toEqual({
linkHeight: 50,
linkWidth: 100,
patternThumbnailUrl: undefined,
thumbnail: undefined,
type: 'webpage',
url: 'https://x.com/post/123',
});
expect(getCommentMediaInfo('https://www.youtube.com/watch?v=abc123', '', 800, 450)).toEqual({
linkHeight: 450,
linkWidth: 800,
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
thumbnail: undefined,
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
});
});
it('returns expected media dimensions for embeds, audio, and sized media', () => {
testState.canEmbedHosts = new Set(['www.youtube.com', 'www.reddit.com']);
expect(getMediaDimensions({ type: 'iframe', url: 'https://www.youtube.com/watch?v=abc123' })).toBe('800x450');
expect(getMediaDimensions({ type: 'iframe', url: 'https://www.reddit.com/r/example/comments/abc123' })).toBe('500x520');
expect(getMediaDimensions({ type: 'audio', url: 'https://example.com/file.mp3' })).toBe('700x240');
expect(getMediaDimensions({ linkHeight: 480, linkWidth: 640, type: 'image', url: 'https://example.com/file.png' })).toBe('640x480');
expect(getMediaDimensions({ linkHeight: 720, linkWidth: 1280, type: 'video', url: 'https://example.com/file.mp4' })).toBe('1280x720');
expect(getMediaDimensions({ type: 'webpage', url: 'https://example.com' })).toBe('');
});
it('uses cached webpage thumbnails before fetching the network', async () => {
testState.cachedThumbnails.set('https://example.com/cached', 'https://cdn.example/cached.png');
const result = await fetchWebpageThumbnailIfNeeded({
type: 'webpage',
url: 'https://example.com/cached',
});
expect(result).toEqual({
thumbnail: 'https://cdn.example/cached.png',
type: 'webpage',
url: 'https://example.com/cached',
});
expect(testState.fetchMock).not.toHaveBeenCalled();
});
it('fetches og:image thumbnails on web and persists them', async () => {
testState.fetchMock.mockResolvedValue(
createFetchResponse(`
<html>
<head><meta property="og:image" content="https://cdn.example/og.png" /></head>
<body></body>
</html>
`),
);
const result = await fetchWebpageThumbnailIfNeeded({
type: 'webpage',
url: 'https://example.com/og-page',
});
expect(testState.fetchMock).toHaveBeenCalledWith('https://example.com/og-page', expect.objectContaining({ headers: { Accept: 'text/html' } }));
expect(testState.localForageSetItemMock).toHaveBeenCalledWith('https://example.com/og-page', 'https://cdn.example/og.png');
expect(result).toEqual({
thumbnail: 'https://cdn.example/og.png',
type: 'webpage',
url: 'https://example.com/og-page',
});
});
it('fetches first-image thumbnails on native and resolves relative urls', async () => {
testState.isNativePlatform = true;
testState.capacitorHttpGetMock.mockResolvedValue({
data: `
<html>
<body><img src="/poster.png" /></body>
</html>
`,
});
const result = await fetchWebpageThumbnailIfNeeded({
type: 'webpage',
url: 'https://example.com/native-page',
});
expect(testState.capacitorHttpGetMock).toHaveBeenCalledWith(
expect.objectContaining({
connectTimeout: 5000,
headers: { Accept: 'text/html', Range: 'bytes=0-1048575' },
readTimeout: 5000,
responseType: 'text',
url: 'https://example.com/native-page',
}),
);
expect(result).toEqual({
thumbnail: 'https://example.com/poster.png',
type: 'webpage',
url: 'https://example.com/native-page',
});
});
it('returns unchanged media when thumbnails already exist or fetching fails', async () => {
const existing = {
thumbnail: 'https://cdn.example/existing.png',
type: 'webpage',
url: 'https://example.com/ready',
} as const;
expect(await fetchWebpageThumbnailIfNeeded(existing)).toBe(existing);
testState.fetchMock.mockResolvedValue(createFetchResponse('<html></html>', false));
const result = await fetchWebpageThumbnailIfNeeded({
type: 'webpage',
url: 'https://example.com/failure',
});
expect(result).toEqual({
thumbnail: undefined,
type: 'webpage',
url: 'https://example.com/failure',
});
expect(consoleErrorSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const testState = vi.hoisted(() => ({
consoleErrorMock: vi.fn(),
subplebbits: {} as Record<string, { roles?: Record<string, { role?: string }> }>,
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits', () => ({
default: {
getState: () => ({
subplebbits: testState.subplebbits,
}),
},
}));
import { commentMatchesPattern, displayNameMatchesPattern, matchesPattern, parsePattern, userHasRole, userIdMatchesPattern } from '../pattern-utils';
describe('pattern-utils', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
testState.subplebbits = {
'music-posting.eth': {
roles: {
'author-1': { role: 'moderator' },
'author-2': { role: 'owner' },
},
},
};
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(testState.consoleErrorMock);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('matches whole words, exact phrases, regexes, wildcards, and logical operators', () => {
expect(matchesPattern('That feel when the girlfriend texts back', 'feel')).toBe(true);
expect(matchesPattern('That feel when the girlfriend texts back', 'feels')).toBe(false);
expect(matchesPattern('That feel when the girlfriend texts back', 'feel girlfriend')).toBe(true);
expect(matchesPattern('That feel when the girlfriend texts back', 'girlfriend|boyfriend feel')).toBe(true);
expect(matchesPattern('That feel when the girlfriend texts back', '"feel when the girlfriend"')).toBe(true);
expect(matchesPattern('That feeling stays forever', 'feel*')).toBe(true);
expect(matchesPattern('MIXED Case Example', '/mixed case example/i')).toBe(true);
});
it('falls back to a simple include when pattern parsing throws', () => {
expect(matchesPattern('the broken marker foo(', 'foo(')).toBe(true);
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('matches user ids through full and short addresses', () => {
const comment = {
author: {
address: '12D3KooWabcdef',
shortAddress: '12D3KooWabc',
},
};
expect(userIdMatchesPattern(comment as never, 'abcdef')).toBe(true);
expect(userIdMatchesPattern(comment as never, '12D3KooWabc')).toBe(true);
expect(userIdMatchesPattern(comment as never, 'missing')).toBe(false);
});
it('matches display names case-insensitively and treats anonymous as undefined display names', () => {
expect(displayNameMatchesPattern({ author: { displayName: 'Alice' } } as never, 'alice')).toBe(true);
expect(displayNameMatchesPattern({ author: {} } as never, 'anonymous')).toBe(true);
expect(displayNameMatchesPattern({ author: { displayName: 'Bob' } } as never, 'anonymous')).toBe(false);
});
it('matches roles with moderator aliases and rejects missing role metadata', () => {
const modComment = {
author: { address: 'author-1' },
subplebbitAddress: 'music-posting.eth',
};
const ownerComment = {
author: { address: 'author-2' },
subplebbitAddress: 'music-posting.eth',
};
expect(userHasRole(modComment as never, 'moderator')).toBe(true);
expect(userHasRole(modComment as never, 'mod')).toBe(true);
expect(userHasRole(ownerComment as never, 'owner')).toBe(true);
expect(userHasRole(ownerComment as never, 'admin')).toBe(false);
expect(userHasRole({ author: { address: 'missing' }, subplebbitAddress: 'unknown.eth' } as never, 'moderator')).toBe(false);
});
it('parses mixed special filters and content filters', () => {
expect(parsePattern('#abc ##Alice #!#mod exact phrase')).toEqual({
contentFilter: 'exact phrase',
specialFilters: [
{ type: 'userId', value: 'abc' },
{ type: 'displayName', value: 'Alice' },
{ type: 'role', value: 'mod' },
],
});
expect(parsePattern('')).toEqual({
contentFilter: '',
specialFilters: [],
});
});
it('matches comments against combined special filters and content filters', () => {
const comment = {
author: {
address: 'author-1',
displayName: 'Alice',
shortAddress: 'auth1',
},
content: 'That feel when the girlfriend texts back',
subplebbitAddress: 'music-posting.eth',
title: 'TFW',
};
expect(commentMatchesPattern(comment as never, '#auth1 ##Alice #!#moderator girlfriend')).toBe(true);
expect(commentMatchesPattern(comment as never, '#auth1 ##Bob #!#moderator girlfriend')).toBe(false);
expect(commentMatchesPattern(comment as never, '#auth1')).toBe(true);
expect(commentMatchesPattern(comment as never, '##Alice')).toBe(true);
expect(commentMatchesPattern(comment as never, '#!#mod')).toBe(true);
expect(commentMatchesPattern(comment as never, 'tfw girlfriend')).toBe(true);
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { findPostPageInFeed, findPostPageInLoadedBoardFeeds, isBoardFeedOptions } from '../post-page-resolution';
describe('post-page-resolution', () => {
it('finds the GUI page for a post inside a feed and rejects invalid inputs', () => {
const feed = [{ cid: 'post-1' }, { cid: 'post-2' }, { cid: 'post-3' }, { cid: 'post-4' }];
expect(findPostPageInFeed(feed, 'post-3', 2)).toBe(2);
expect(findPostPageInFeed(feed, 'missing-post', 2)).toBeUndefined();
expect(findPostPageInFeed(feed, 'post-1', 0)).toBeUndefined();
expect(findPostPageInFeed(feed, '', 2)).toBeUndefined();
});
it('only accepts strict board feed options for the active single-board feed', () => {
const baseOptions = {
sortType: 'active',
subplebbitAddresses: ['music.eth'],
};
expect(isBoardFeedOptions(baseOptions, 'music.eth')).toBe(true);
expect(isBoardFeedOptions({ ...baseOptions, sortType: 'new' }, 'music.eth')).toBe(false);
expect(isBoardFeedOptions({ ...baseOptions, subplebbitAddresses: ['music.eth', 'tech.eth'] }, 'music.eth')).toBe(false);
expect(isBoardFeedOptions({ ...baseOptions, subplebbitAddresses: ['tech.eth'] }, 'music.eth')).toBe(false);
expect(isBoardFeedOptions({ ...baseOptions, filter: { title: 'test' } }, 'music.eth')).toBe(false);
expect(isBoardFeedOptions({ ...baseOptions, newerThan: 3600 }, 'music.eth')).toBe(false);
expect(isBoardFeedOptions({ ...baseOptions, modQueue: true }, 'music.eth')).toBe(false);
expect(isBoardFeedOptions({ ...baseOptions, accountComments: true }, 'music.eth')).toBe(false);
});
it('resolves a post page from matching loaded board feeds and ignores unrelated feeds', () => {
const feedsOptions = {
allFeed: {
sortType: 'active',
subplebbitAddresses: ['all.eth'],
},
catalogFilterFeed: {
filter: { title: 'match' },
sortType: 'active',
subplebbitAddresses: ['music.eth'],
},
boardFeed: {
sortType: 'active',
subplebbitAddresses: ['music.eth'],
},
};
const loadedFeeds = {
allFeed: [{ cid: 'post-4' }],
boardFeed: [{ cid: 'post-1' }, { cid: 'post-2' }, { cid: 'post-3' }, { cid: 'post-4' }],
};
expect(findPostPageInLoadedBoardFeeds(feedsOptions, loadedFeeds, 'music.eth', 'post-4', 2)).toBe(2);
expect(findPostPageInLoadedBoardFeeds(feedsOptions, loadedFeeds, 'music.eth', 'missing-post', 2)).toBeUndefined();
expect(findPostPageInLoadedBoardFeeds(feedsOptions, { boardFeed: 'not-an-array' as never }, 'music.eth', 'post-4', 2)).toBeUndefined();
});
});