Files
5chan/src/views/mod-queue/__tests__/mod-queue.test.tsx
T
Tommaso CasaburiandGitHub 12977fa24a fix(mod-queue): rework excerpt hover preview, scope pending-age alerts (#1152)
Render the compact mod queue excerpt hover preview like the quote-link previews (clean read-only Post via useFloating, anchored to the right of the excerpt on desktop / below on mobile), cap its content at 350 chars, and scope the red pending-age timestamp to posts actually awaiting approval. Adds keyboard handlers to the span fallback.
2026-06-05 17:29:04 +07:00

326 lines
11 KiB
TypeScript

import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ModQueueView from '../mod-queue';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (callback: () => void | Promise<void>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
type TestComment = {
cid: string;
content?: string;
communityAddress?: string;
number?: number;
pendingApproval?: boolean;
timestamp?: number;
};
const testState = vi.hoisted(() => ({
account: { author: { address: '0x123' }, id: 'account' },
accountCommunityAddresses: ['music-posting.eth'],
addChallengeMock: vi.fn(),
directories: [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }],
dismissedCommentCids: [] as string[],
feed: [] as TestComment[],
hasMore: false,
isMobile: false,
loadMoreMock: vi.fn(),
publishCommentModerationMock: vi.fn(),
queuedCommentHistory: [] as TestComment[],
rememberCommentsInQueueMock: vi.fn(),
resetMock: vi.fn(),
selectedBoardFilter: null as string | null,
setResetFunctionMock: vi.fn(),
viewMode: 'compact' as 'compact' | 'feed',
}));
const getModQueueState = () => ({
dismissedCommentCids: testState.dismissedCommentCids,
dismissCommentFromQueue: vi.fn(),
getAlertThresholdSeconds: () => 6 * 60 * 60,
queuedCommentHistory: testState.queuedCommentHistory,
rememberCommentsInQueue: testState.rememberCommentsInQueueMock,
selectedBoardFilter: testState.selectedBoardFilter,
setSelectedBoardFilter: vi.fn(),
setViewMode: vi.fn(),
viewMode: testState.viewMode,
});
function useModQueueStoreMock<T>(selector?: (state: ReturnType<typeof getModQueueState>) => T) {
const state = getModQueueState();
return selector ? selector(state) : (state as T);
}
useModQueueStoreMock.getState = getModQueueState;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useCommunity: () => ({
roles: {
'0x123': { role: 'moderator' },
},
state: 'succeeded',
}),
useCommunities: ({ communities }: { communities?: Array<{ name: string }> } = {}) => ({
communities: (communities ?? []).map(() => ({
roles: {
'0x123': { role: 'moderator' },
},
})),
}),
useEditedComment: ({ comment }: { comment?: TestComment }) => ({
editedComment: comment,
failedEdits: {},
pendingEdits: {},
succeededEdits: {},
}),
useFeed: () => ({
feed: testState.feed,
hasMore: testState.hasMore,
loadMore: testState.loadMoreMock,
reset: testState.resetMock,
state: testState.hasMore ? 'fetching-ipns' : 'succeeded',
}),
usePublishCommentModeration: () => ({
publishCommentModeration: testState.publishCommentModerationMock,
state: 'initializing',
}),
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () => ({
default: (selector: (state: { accountsEditsSummaries: Record<string, Record<string, unknown>>; activeAccountId: string }) => unknown) =>
selector({ accountsEditsSummaries: { account: {} }, activeAccountId: 'account' }),
}));
vi.mock('@floating-ui/react', () => ({
autoUpdate: vi.fn(),
flip: vi.fn(),
offset: vi.fn(),
shift: vi.fn(),
size: vi.fn(),
useFloating: () => ({
floatingStyles: { position: 'fixed' },
refs: {
setFloating: () => undefined,
setReference: () => undefined,
},
update: vi.fn(),
}),
}));
vi.mock('react-virtuoso', () => ({
Virtuoso: ({
components,
data = [],
itemContent,
}: {
components?: { Footer?: React.ComponentType };
data?: TestComment[];
itemContent: (index: number, item: TestComment) => React.ReactNode;
}) =>
createElement(
'div',
{ 'data-testid': 'virtuoso' },
data.map((item, index) => createElement(React.Fragment, { key: item.cid }, itemContent(index, item))),
components?.Footer ? createElement(components.Footer) : null,
),
}));
vi.mock('../../../stores/use-mod-queue-store', () => ({
default: useModQueueStoreMock,
}));
vi.mock('../../../stores/use-feed-reset-store', () => ({
default: (selector: (state: { setResetFunction: typeof testState.setResetFunctionMock }) => unknown) => selector({ setResetFunction: testState.setResetFunctionMock }),
}));
vi.mock('../../../stores/use-challenges-store', () => {
const useChallengesStore = () => ({});
useChallengesStore.getState = () => ({ addChallenge: testState.addChallengeMock });
return { default: useChallengesStore };
});
vi.mock('../../../hooks/use-account-community-addresses', () => ({
useAccountCommunityAddresses: () => testState.accountCommunityAddresses,
}));
vi.mock('../../../hooks/use-community-identifiers', () => ({
useCommunityIdentifier: (address: string | undefined) => (address ? { name: address } : undefined),
useCommunityIdentifiers: (addresses: string[]) => addresses.map((address) => ({ name: address })),
}));
vi.mock('../../../hooks/use-current-time', () => ({
useCurrentTime: () => 100_000,
}));
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
}));
vi.mock('../../../hooks/use-is-mobile', () => ({
default: () => testState.isMobile,
}));
vi.mock('../../../hooks/use-state-string', () => ({
useFeedStateString: () => 'loading_mod_queue',
}));
vi.mock('../../../components/error-display/error-display', () => ({
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'error'),
}));
vi.mock('../../../components/footer', () => ({
PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-desktop' }, firstRow),
PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-mobile' }, children),
StyleOnlyFooterFirstRow: () => createElement('div', { 'data-testid': 'style-footer-row' }),
}));
vi.mock('../../../components/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
}));
vi.mock('../../../components/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => createElement(React.Fragment, {}, children),
}));
vi.mock('../../post/post', () => ({
Post: ({ isModQueue, post, showReplies }: { isModQueue?: boolean; post?: TestComment; showReplies?: boolean }) =>
createElement(
'div',
{
'data-cid': post?.cid,
'data-content': post?.content,
'data-is-mod-queue': String(Boolean(isModQueue)),
'data-show-replies': String(Boolean(showReplies)),
'data-testid': 'mod-queue-feed-post',
},
post?.cid ?? 'missing',
),
}));
let container: HTMLDivElement;
let root: Root;
const renderModQueue = async () => {
await act(async () => {
root.render(
createElement(
MemoryRouter,
{ initialEntries: ['/mod/queue'] },
createElement(Routes, {}, createElement(Route, { path: '/mod/queue', element: createElement(ModQueueView) })),
),
);
});
};
describe('ModQueueView', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.dismissedCommentCids = [];
testState.feed = [];
testState.hasMore = false;
testState.isMobile = false;
testState.queuedCommentHistory = [];
testState.selectedBoardFilter = null;
testState.viewMode = 'compact';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it('keeps the compact table hidden while an empty mod queue is still loading', async () => {
testState.hasMore = true;
await renderModQueue();
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('loading_mod_queue');
expect(container.textContent).not.toContain('No.');
expect(container.textContent).not.toContain('queue_is_empty');
});
it('keeps the compact table visible and renders the empty state under its header after loading', async () => {
testState.hasMore = false;
await renderModQueue();
const text = container.textContent ?? '';
expect(text).toContain('No.');
expect(text).toContain('excerpt');
expect(text).toContain('queue_is_empty');
expect(text.indexOf('No.')).toBeLessThan(text.indexOf('queue_is_empty'));
});
it('opens a full floating post preview from a compact excerpt hover', async () => {
testState.feed = [
{
cid: 'pending-reply',
communityAddress: 'music-posting.eth',
content: 'pending reply body',
number: 7,
pendingApproval: true,
timestamp: 90_000,
},
];
await renderModQueue();
const excerptLink = Array.from(container.querySelectorAll<HTMLAnchorElement>('a')).find((link) => link.textContent === 'pending reply body');
expect(excerptLink).toBeTruthy();
expect(document.body.querySelector('[data-mod-queue-excerpt-preview="true"]')).toBeNull();
await act(async () => {
excerptLink?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
});
const previewPost = document.body.querySelector('[data-mod-queue-excerpt-preview="true"] [data-testid="mod-queue-feed-post"]');
expect(previewPost?.getAttribute('data-cid')).toBe('pending-reply');
// Rendered like a quote-link hover preview: a clean read-only Post, not the
// mod-queue feed layout (no leading <hr>, no inline approve/reject buttons).
expect(previewPost?.getAttribute('data-is-mod-queue')).toBe('false');
expect(previewPost?.getAttribute('data-show-replies')).toBe('false');
});
it('caps long content in the floating preview so the hover card stays compact', async () => {
testState.feed = [
{
cid: 'long-post',
communityAddress: 'music-posting.eth',
content: 'x'.repeat(500),
number: 9,
pendingApproval: true,
timestamp: 90_000,
},
];
await renderModQueue();
const excerptLink = Array.from(container.querySelectorAll<HTMLAnchorElement>('a')).find((link) => link.textContent?.startsWith('xxx'));
expect(excerptLink).toBeTruthy();
await act(async () => {
excerptLink?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
});
const previewPost = document.body.querySelector('[data-mod-queue-excerpt-preview="true"] [data-testid="mod-queue-feed-post"]');
const previewContent = previewPost?.getAttribute('data-content') ?? '';
// 350-char cap + a single ellipsis character (shorter than the feed's 1000).
expect(previewContent.length).toBe(351);
expect(previewContent.endsWith('…')).toBe(true);
});
});