mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
517 lines
16 KiB
TypeScript
517 lines
16 KiB
TypeScript
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 PostDesktop from '../post-desktop';
|
|
import PostMobile from '../post-mobile';
|
|
|
|
(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>;
|
|
|
|
type TestComment = {
|
|
author?: {
|
|
address?: string;
|
|
shortAddress?: string;
|
|
};
|
|
cid?: string;
|
|
communityAddress?: string;
|
|
content?: string;
|
|
deleted?: boolean;
|
|
index?: number;
|
|
link?: string;
|
|
linkHeight?: number;
|
|
linkWidth?: number;
|
|
number?: number;
|
|
parentCid?: string;
|
|
pinned?: boolean;
|
|
postCid?: string;
|
|
removed?: boolean;
|
|
replyCount?: number;
|
|
replies?: {
|
|
pages?: Record<
|
|
string,
|
|
{
|
|
comments?: TestComment[];
|
|
}
|
|
>;
|
|
};
|
|
state?: string;
|
|
thumbnailUrl?: string;
|
|
timestamp?: number;
|
|
updatedAt?: number;
|
|
};
|
|
|
|
const testState = vi.hoisted(() => ({
|
|
addChallengeMock: vi.fn(),
|
|
hasMoreReplies: false,
|
|
openReplyModalMock: vi.fn(),
|
|
replyComments: [] as Array<TestComment | undefined>,
|
|
setResetFunctionMock: vi.fn(),
|
|
virtuosoProps: [] as Array<{ defaultItemHeight?: number; heightEstimates?: number[]; itemSize?: unknown }>,
|
|
}));
|
|
|
|
const getMockPreloadedReplies = (comment?: TestComment, sortType?: string) => {
|
|
if (!comment) {
|
|
return [];
|
|
}
|
|
|
|
const preloadedReplies =
|
|
comment.replies?.pages?.[sortType || 'best']?.comments ?? Object.values(comment.replies?.pages ?? {}).find((page) => page?.comments?.length)?.comments ?? [];
|
|
|
|
const compatibleReplies: TestComment[] = [];
|
|
for (const reply of preloadedReplies) {
|
|
if (!reply?.communityAddress || reply.communityAddress !== comment.communityAddress) {
|
|
break;
|
|
}
|
|
compatibleReplies.push(reply);
|
|
}
|
|
|
|
return compatibleReplies;
|
|
};
|
|
|
|
vi.mock('react-i18next', () => ({
|
|
Trans: ({ i18nKey, values }: { i18nKey?: string; values?: Record<string, unknown> }) =>
|
|
createElement('span', {}, `${i18nKey ?? 'trans'}:${JSON.stringify(values ?? {})}`),
|
|
useTranslation: () => ({
|
|
t: (key: string) => key,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
|
useAccount: () => ({ author: { address: '0xviewer' } }),
|
|
useAccountComment: () => undefined,
|
|
useEditedComment: () => ({ editedComment: undefined }),
|
|
usePublishCommentModeration: () => ({
|
|
error: undefined,
|
|
publishCommentModeration: vi.fn(),
|
|
state: 'initializing',
|
|
}),
|
|
useReplies: ({ comment, sortType }: { comment?: TestComment; sortType?: string }) => {
|
|
testState.replyComments.push(comment);
|
|
return {
|
|
hasMore: testState.hasMoreReplies,
|
|
loadMore: vi.fn(),
|
|
replies: getMockPreloadedReplies(comment, sortType),
|
|
};
|
|
},
|
|
}));
|
|
|
|
vi.mock('react-virtuoso', () => ({
|
|
Virtuoso: React.forwardRef(
|
|
(
|
|
{
|
|
components,
|
|
data = [],
|
|
defaultItemHeight,
|
|
heightEstimates,
|
|
itemSize,
|
|
itemContent,
|
|
}: {
|
|
components?: { Footer?: React.ComponentType };
|
|
data?: TestComment[];
|
|
defaultItemHeight?: number;
|
|
heightEstimates?: number[];
|
|
itemSize?: unknown;
|
|
itemContent: (index: number, item: TestComment) => React.ReactNode;
|
|
},
|
|
ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>,
|
|
) => {
|
|
testState.virtuosoProps.push({ defaultItemHeight, heightEstimates, itemSize });
|
|
|
|
React.useImperativeHandle(ref, () => ({
|
|
getState: (cb) => cb({ ranges: [0], scrollTop: 0 }),
|
|
}));
|
|
|
|
return createElement(
|
|
'div',
|
|
{ 'data-testid': 'virtuoso' },
|
|
data.map((item, index) => createElement('div', { key: item.cid ?? index }, itemContent(index, item))),
|
|
components?.Footer ? createElement(components.Footer) : null,
|
|
);
|
|
},
|
|
),
|
|
}));
|
|
|
|
vi.mock('../../lib/get-short-address', () => ({
|
|
default: (value?: string) => (value ? value.slice(0, 4) : ''),
|
|
}));
|
|
|
|
vi.mock('../../views/post/post.module.css', () => ({
|
|
default: new Proxy(
|
|
{},
|
|
{
|
|
get: (_target, property) => String(property),
|
|
},
|
|
),
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/media-utils', () => ({
|
|
getDisplayMediaInfoType: (type?: string) => type ?? 'unknown',
|
|
getHasThumbnail: () => true,
|
|
getMediaDimensions: () => '100x100',
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/post-utils', () => ({
|
|
getTextColorForBackground: () => '#fff',
|
|
hashStringToColor: () => '#000',
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/time-utils', () => ({
|
|
getFormattedDate: () => '2026-03-13',
|
|
getFormattedTimeAgo: () => 'moments ago',
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/pending-approval-moderation', () => ({
|
|
approvePendingCommentModeration: {},
|
|
isPendingApprovalRejected: () => false,
|
|
rejectPendingCommentModeration: {},
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/url-utils', () => ({
|
|
isValidURL: () => true,
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/view-utils', () => ({
|
|
isAllView: (pathname: string) => pathname === '/all',
|
|
isModQueueView: () => false,
|
|
isModView: () => false,
|
|
isPendingPostView: () => false,
|
|
isPostPageView: (pathname: string) => pathname.includes('/thread/'),
|
|
isSubscriptionsView: () => false,
|
|
}));
|
|
|
|
vi.mock('../../stores/use-mod-queue-store', () => ({
|
|
default: (selector?: (state: { getAlertThresholdSeconds: () => number }) => unknown) => {
|
|
const state = {
|
|
getAlertThresholdSeconds: () => 0,
|
|
};
|
|
|
|
return selector ? selector(state) : state;
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-directories', () => ({
|
|
findDirectoryByAddress: (_directories: unknown[], address?: string) => (address ? { address, features: {} } : undefined),
|
|
useDirectories: () => [{ address: 'music-posting.eth', title: '/mu/ - Music' }],
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/route-utils', () => ({
|
|
getBoardPath: (address?: string) => (address ? 'mu' : undefined),
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-author-address-click', () => ({
|
|
default: () => vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-comment-media-info', () => ({
|
|
useCommentMediaInfo: (link?: string) => (link ? { type: 'image', url: link } : undefined),
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-count-links-in-replies', () => ({
|
|
default: () => 0,
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-fetch-gif-first-frame', () => ({
|
|
default: () => ({
|
|
status: 'idle',
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-hide', () => ({
|
|
default: () => ({
|
|
hidden: false,
|
|
hide: vi.fn(),
|
|
unhide: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-state-string', () => ({
|
|
default: () => undefined,
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-scroll-to-reply', () => ({
|
|
default: () => undefined,
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-current-time', () => ({
|
|
useCurrentTime: () => 1_710_000_000,
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-board-pseudonymity-mode', () => ({
|
|
useBoardPseudonymityMode: () => 'none',
|
|
}));
|
|
|
|
vi.mock('../comment-content', () => ({
|
|
default: ({ comment }: { comment?: TestComment }) => createElement('div', { 'data-testid': 'comment-content' }, comment?.cid ?? 'missing'),
|
|
}));
|
|
|
|
vi.mock('../comment-media', () => ({
|
|
default: () => createElement('div', { 'data-testid': 'comment-media' }, 'media'),
|
|
}));
|
|
|
|
vi.mock('../edit-menu/edit-menu', () => ({
|
|
default: () => createElement('div', { 'data-testid': 'edit-menu' }, 'edit'),
|
|
}));
|
|
|
|
vi.mock('../failed-publish-notice', () => ({
|
|
default: () => createElement('div', { 'data-testid': 'failed-publish-notice' }, 'failed-publish-notice'),
|
|
}));
|
|
|
|
vi.mock('../embed', () => ({
|
|
canEmbed: () => false,
|
|
}));
|
|
|
|
vi.mock('../loading-ellipsis', () => ({
|
|
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
|
|
}));
|
|
|
|
vi.mock('../post-desktop/post-menu-desktop', () => ({
|
|
default: ({ postMenu }: { postMenu: { communityAddress?: string } }) =>
|
|
createElement('div', { 'data-testid': 'post-menu-desktop' }, postMenu.communityAddress ?? 'missing'),
|
|
}));
|
|
|
|
vi.mock('../reply-quote-preview', () => ({
|
|
default: ({ backlinkReply }: { backlinkReply?: TestComment }) => createElement('div', { 'data-testid': 'reply-quote-preview' }, backlinkReply?.cid ?? 'missing'),
|
|
}));
|
|
|
|
vi.mock('../tooltip', () => ({
|
|
default: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children),
|
|
}));
|
|
|
|
vi.mock('../../lib/snow', () => ({
|
|
shouldShowSnow: () => false,
|
|
}));
|
|
|
|
vi.mock('../../stores/use-reply-modal-store', () => ({
|
|
default: () => ({
|
|
openReplyModal: testState.openReplyModalMock,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../stores/use-challenges-store', () => ({
|
|
default: {
|
|
getState: () => ({
|
|
addChallenge: testState.addChallengeMock,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../stores/use-feed-reset-store', () => ({
|
|
default: (selector: (state: { setResetFunction: typeof testState.setResetFunctionMock }) => unknown) =>
|
|
selector({
|
|
setResetFunction: testState.setResetFunctionMock,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-register-fresh-replies', () => ({
|
|
default: () => undefined,
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/challenge-utils', () => ({
|
|
alertChallengeVerificationFailed: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-quoted-by-map', () => ({
|
|
default: () => new Map(),
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-progressive-render', () => ({
|
|
default: (replies: TestComment[]) => replies,
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-fresh-replies', () => ({
|
|
default: (replies: TestComment[]) => replies,
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-reply-height-estimates', () => ({
|
|
default: ({ isMobile, replies }: { isMobile: boolean; replies: TestComment[] }) => ({
|
|
defaultItemHeight: isMobile ? 222 : 111,
|
|
heightEstimates: replies.map((_, index) => (isMobile ? 200 : 100) + index),
|
|
itemSize: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../lib/constants', () => ({
|
|
BOARD_REPLIES_PREVIEW_FETCH_SIZE: 5,
|
|
BOARD_REPLIES_PREVIEW_VISIBLE_COUNT: 3,
|
|
REPLIES_PER_PAGE: 20,
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/replies-preview-utils', () => ({
|
|
computeOmittedCount: () => 0,
|
|
filterRepliesForDisplay: (replies: TestComment[]) => replies,
|
|
getPreviewDisplayReplies: (replies: TestComment[]) => replies,
|
|
getTotalReplyCount: ({ replyCount }: { replyCount?: number }) => replyCount ?? 0,
|
|
hasEnoughPreviewReplies: ({ replyCount, loadedCount, visibleCount }: { replyCount?: number; loadedCount: number; visibleCount: number }) =>
|
|
loadedCount >= Math.min(visibleCount, replyCount ?? visibleCount),
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/thread-scroll-utils', () => ({
|
|
getThreadTopNavigationState: () => undefined,
|
|
scrollThreadContainerToTop: () => true,
|
|
}));
|
|
|
|
vi.mock('../../hooks/use-delete-failed-post', () => ({
|
|
default: () => ({
|
|
canDeleteFailedPost: false,
|
|
canRetryFailedPost: false,
|
|
isDeletingFailedPost: false,
|
|
isRetryingFailedPost: false,
|
|
onDeleteFailedPost: vi.fn(),
|
|
onRetryFailedPost: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../post-mobile/post-menu-mobile', () => ({
|
|
default: ({ postMenu }: { postMenu: { communityAddress?: string } }) =>
|
|
createElement('div', { 'data-testid': 'post-menu-mobile' }, postMenu.communityAddress ?? 'missing'),
|
|
}));
|
|
|
|
vi.mock('../../lib/utils/reply-backlink-utils', () => ({
|
|
getRenderableMobileBacklinks: () => ({
|
|
directReplyBacklinks: [],
|
|
opBacklinks: [],
|
|
quotedReplyBacklinks: [],
|
|
}),
|
|
}));
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
const flushEffects = async (count = 3) => {
|
|
for (let i = 0; i < count; i += 1) {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
};
|
|
|
|
const renderWithRoute = async (element: React.ReactNode, initialEntry = '/all') => {
|
|
await act(async () => {
|
|
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, element));
|
|
});
|
|
await flushEffects();
|
|
};
|
|
|
|
const makeLegacyThread = (): TestComment => ({
|
|
author: { address: '0xauthor', shortAddress: 'anon' },
|
|
cid: 'post-1',
|
|
content: 'Original post',
|
|
link: 'https://example.com/file.png',
|
|
linkHeight: 100,
|
|
linkWidth: 100,
|
|
number: 1,
|
|
postCid: 'post-1',
|
|
replyCount: 1,
|
|
replies: {
|
|
pages: {
|
|
new: {
|
|
comments: [
|
|
{
|
|
author: { address: '0xreply', shortAddress: 'reply' },
|
|
cid: 'reply-1',
|
|
content: 'Reply',
|
|
number: 2,
|
|
parentCid: 'post-1',
|
|
postCid: 'post-1',
|
|
communityAddress: 'music-posting.eth',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
communityAddress: 'music-posting.eth',
|
|
timestamp: 1_710_000_000,
|
|
});
|
|
|
|
const makeLegacyThreadWithoutReplies = (): TestComment => ({
|
|
...makeLegacyThread(),
|
|
replyCount: 0,
|
|
replies: {
|
|
pages: {
|
|
new: {
|
|
comments: [],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
describe('post community address compatibility', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
testState.hasMoreReplies = false;
|
|
testState.replyComments = [];
|
|
testState.virtuosoProps = [];
|
|
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
it('renders desktop multiboard posts with only communityAddress and still fetches replies', async () => {
|
|
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread() }));
|
|
|
|
const primaryRepliesComment = testState.replyComments.find((comment) => comment?.cid === 'post-1');
|
|
expect(primaryRepliesComment?.communityAddress).toBe('music-posting.eth');
|
|
expect(primaryRepliesComment?.replies?.pages?.new?.comments?.[0]?.communityAddress).toBe('music-posting.eth');
|
|
expect(container.querySelector('[data-testid="post-menu-desktop"]')?.textContent).toBe('music-posting.eth');
|
|
expect(document.body.querySelector('a[href="/mu"]')?.textContent).toContain('mu');
|
|
expect(container.querySelector('[data-testid="comment-media"]')).toBeTruthy();
|
|
expect(container.textContent).toContain('reply-1');
|
|
});
|
|
|
|
it('renders mobile multiboard posts with only communityAddress and still fetches replies', async () => {
|
|
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread() }));
|
|
|
|
const primaryRepliesComment = testState.replyComments.find((comment) => comment?.cid === 'post-1');
|
|
expect(primaryRepliesComment?.communityAddress).toBe('music-posting.eth');
|
|
expect(primaryRepliesComment?.replies?.pages?.new?.comments?.[0]?.communityAddress).toBe('music-posting.eth');
|
|
expect(container.querySelector('[data-testid="post-menu-mobile"]')?.textContent).toBe('music-posting.eth');
|
|
expect(document.body.querySelector('a[href="/mu"]')?.textContent).toContain('Board: mu');
|
|
expect(container.querySelector('[data-testid="comment-media"]')).toBeTruthy();
|
|
expect(container.textContent).toContain('reply-1');
|
|
});
|
|
|
|
it('forwards Pretext-backed reply estimates into Virtuoso for desktop and mobile thread views', async () => {
|
|
testState.hasMoreReplies = true;
|
|
|
|
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread(), showAllReplies: true }), '/mu/thread/post-1');
|
|
expect(testState.virtuosoProps.at(-1)).toEqual({
|
|
defaultItemHeight: 111,
|
|
heightEstimates: [100],
|
|
itemSize: expect.any(Function),
|
|
});
|
|
|
|
testState.virtuosoProps = [];
|
|
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread(), showAllReplies: true }), '/mu/thread/post-1');
|
|
expect(testState.virtuosoProps.at(-1)).toEqual({
|
|
defaultItemHeight: 222,
|
|
heightEstimates: [200],
|
|
itemSize: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it('keeps board-card Pretext heights when preview replies are rendered', async () => {
|
|
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread() }));
|
|
expect(container.querySelector('.postDesktop')?.getAttribute('data-pretext-height')).toBeTruthy();
|
|
|
|
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread() }));
|
|
expect(container.querySelector('.postMobile')?.getAttribute('data-pretext-height')).toBeTruthy();
|
|
});
|
|
|
|
it('keeps board-card Pretext heights for simple cards without preview replies', async () => {
|
|
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThreadWithoutReplies() }));
|
|
expect(container.querySelector('.postDesktop')?.getAttribute('data-pretext-height')).toBeTruthy();
|
|
|
|
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThreadWithoutReplies() }));
|
|
expect(container.querySelector('.postMobile')?.getAttribute('data-pretext-height')).toBeTruthy();
|
|
});
|
|
});
|