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