mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
test: cover board actions, pagination, and thread routes
Core browsing flows around board controls, pagination, and thread pages were still largely unmeasured in the honest whole-repo report. That left route mismatches, cached-thread hydration, board action wiring, and mod-queue control regressions underprotected in CI.
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
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 { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons';
|
||||
|
||||
(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 DirectoryEntry = {
|
||||
address: string;
|
||||
features?: { requirePostLinkIsMedia?: boolean };
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
accountComment: undefined as { subplebbitAddress?: string } | undefined,
|
||||
alertThresholdUnit: 'minutes' as 'hours' | 'minutes',
|
||||
alertThresholdValue: 5,
|
||||
commentsByCid: {} as Record<string, any>,
|
||||
directories: [
|
||||
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
||||
{ address: 'tech-posting.eth', features: { requirePostLinkIsMedia: true }, title: '/g/ - Technology' },
|
||||
] as DirectoryEntry[],
|
||||
enableInfiniteScroll: false,
|
||||
filter: 'all' as 'all' | 'nsfw' | 'sfw',
|
||||
filteredCount: 0,
|
||||
imageSize: 'Small' as 'Small' | 'Large',
|
||||
isMobile: true,
|
||||
linkCount: 3,
|
||||
navigateMock: vi.fn(),
|
||||
pageNumber: 7 as number | null,
|
||||
resetMock: vi.fn(),
|
||||
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
|
||||
searchText: '',
|
||||
setAlertThresholdMock: vi.fn(),
|
||||
setFilterMock: vi.fn(),
|
||||
setImageSizeMock: vi.fn(),
|
||||
setShowOPCommentMock: vi.fn(),
|
||||
setSortTypeMock: vi.fn(),
|
||||
setViewModeMock: vi.fn(),
|
||||
showOPComment: false,
|
||||
sortType: 'active' as 'active' | 'new' | 'replyCount',
|
||||
subscribeMock: vi.fn(),
|
||||
subscribed: false,
|
||||
unsubscribeMock: vi.fn(),
|
||||
viewMode: 'compact' as 'compact' | 'feed',
|
||||
}));
|
||||
|
||||
function useCatalogFiltersStoreMock<T>(selector?: (state: { filteredCount: number; searchText: string }) => T) {
|
||||
const state = {
|
||||
filteredCount: testState.filteredCount,
|
||||
searchText: testState.searchText,
|
||||
};
|
||||
return selector ? selector(state) : (state as T);
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => testState.navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useAccountComment: () => testState.accountComment,
|
||||
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
|
||||
useSubscribe: () => ({
|
||||
subscribe: testState.subscribeMock,
|
||||
subscribed: testState.subscribed,
|
||||
unsubscribe: testState.unsubscribeMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-post-page-number', () => ({
|
||||
usePostPageNumber: () => testState.pageNumber,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', () => ({
|
||||
useDirectories: () => testState.directories,
|
||||
useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
||||
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-catalog-filters-store', () => ({
|
||||
default: useCatalogFiltersStoreMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-catalog-style-store', () => ({
|
||||
default: () => ({
|
||||
imageSize: testState.imageSize,
|
||||
setImageSize: testState.setImageSizeMock,
|
||||
setShowOPComment: testState.setShowOPCommentMock,
|
||||
showOPComment: testState.showOPComment,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-feed-reset-store', () => ({
|
||||
default: (selector: (state: { reset: typeof testState.resetMock }) => unknown) =>
|
||||
selector({
|
||||
reset: testState.resetMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-sorting-store', () => ({
|
||||
default: () => ({
|
||||
setSortType: testState.setSortTypeMock,
|
||||
sortType: testState.sortType,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-all-feed-filter-store', () => ({
|
||||
default: () => ({
|
||||
filter: testState.filter,
|
||||
setFilter: testState.setFilterMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-mod-queue-store', () => ({
|
||||
default: () => ({
|
||||
alertThresholdUnit: testState.alertThresholdUnit,
|
||||
alertThresholdValue: testState.alertThresholdValue,
|
||||
setAlertThreshold: testState.setAlertThresholdMock,
|
||||
setViewMode: testState.setViewModeMock,
|
||||
viewMode: testState.viewMode,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-feed-view-settings-store', () => ({
|
||||
default: (selector: (state: { enableInfiniteScroll: boolean }) => unknown) =>
|
||||
selector({
|
||||
enableInfiniteScroll: testState.enableInfiniteScroll,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-count-links-in-replies', () => ({
|
||||
default: () => testState.linkCount,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||
default: () => testState.isMobile,
|
||||
}));
|
||||
|
||||
vi.mock('../../catalog-filters', () => ({
|
||||
default: () => createElement('div', { 'data-testid': 'catalog-filters' }, 'catalog-filters'),
|
||||
}));
|
||||
|
||||
vi.mock('../../catalog-search', () => ({
|
||||
default: () => createElement('div', { 'data-testid': 'catalog-search' }, 'catalog-search'),
|
||||
}));
|
||||
|
||||
vi.mock('../../tooltip', () => ({
|
||||
default: ({ content, children }: { content: string; children: React.ReactNode }) =>
|
||||
createElement('span', { 'data-content': content, 'data-testid': 'tooltip' }, children),
|
||||
}));
|
||||
|
||||
vi.mock('../../../views/mod-queue/mod-queue', () => ({
|
||||
ModQueueButton: ({ boardIdentifier, isMobile }: { boardIdentifier?: string; isMobile?: boolean }) =>
|
||||
createElement('div', { 'data-mobile': String(!!isMobile), 'data-testid': 'mod-queue-button' }, boardIdentifier || 'global-mod-queue'),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const renderWithRoute = async (element: React.ReactElement, initialEntry: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: [initialEntry] },
|
||||
createElement(
|
||||
Routes,
|
||||
{},
|
||||
createElement(Route, { path: '/all/catalog', element }),
|
||||
createElement(Route, { path: '/mod/queue', element }),
|
||||
createElement(Route, { path: '/:boardIdentifier/catalog', element }),
|
||||
createElement(Route, { path: '/:boardIdentifier/thread/:commentCid', element }),
|
||||
createElement(Route, { path: '/:boardIdentifier', element }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const clickButton = async (text: string) => {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const changeSelect = async (select: HTMLSelectElement, value: string) => {
|
||||
await act(async () => {
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const setTrackedInputValue = (input: HTMLInputElement, value: string) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
|
||||
descriptor?.set?.call(input, value);
|
||||
};
|
||||
|
||||
describe('BoardButtons', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.accountComment = undefined;
|
||||
testState.alertThresholdUnit = 'minutes';
|
||||
testState.alertThresholdValue = 5;
|
||||
testState.commentsByCid = {};
|
||||
testState.directories = [
|
||||
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
||||
{ address: 'tech-posting.eth', features: { requirePostLinkIsMedia: true }, title: '/g/ - Technology' },
|
||||
];
|
||||
testState.enableInfiniteScroll = false;
|
||||
testState.filter = 'all';
|
||||
testState.filteredCount = 0;
|
||||
testState.imageSize = 'Small';
|
||||
testState.isMobile = true;
|
||||
testState.linkCount = 3;
|
||||
testState.pageNumber = 7;
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
testState.searchText = '';
|
||||
testState.showOPComment = false;
|
||||
testState.sortType = 'active';
|
||||
testState.subscribed = false;
|
||||
testState.viewMode = 'compact';
|
||||
Object.defineProperty(globalThis, 'alert', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', {
|
||||
configurable: true,
|
||||
value: 2400,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders desktop board actions for browsing boards, then searches OPs and triggers refresh, vote, subscribe, and archive flows', async () => {
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/mu');
|
||||
|
||||
expect(container.querySelector('[data-testid="mod-queue-button"]')?.textContent).toBe('mu');
|
||||
expect(container.textContent).toContain('subscribe');
|
||||
expect(container.textContent).toContain('vote');
|
||||
|
||||
const searchInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
expect(searchInput).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
searchInput.value = 'cats';
|
||||
searchInput.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?q=cats');
|
||||
|
||||
await clickButton('refresh');
|
||||
await clickButton('subscribe');
|
||||
await clickButton('vote');
|
||||
await clickButton('archive');
|
||||
|
||||
expect(testState.resetMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.subscribeMock).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.alert).toHaveBeenNthCalledWith(1, 'vote_button_unavailable_intro\n\nvote_button_unavailable_outro');
|
||||
expect(globalThis.alert).toHaveBeenNthCalledWith(2, 'Work in progress');
|
||||
});
|
||||
|
||||
it('renders desktop catalog controls and wires sort, style, filter, and refresh updates', async () => {
|
||||
testState.filteredCount = 4;
|
||||
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog');
|
||||
|
||||
expect(container.textContent).toContain('filtered_threads');
|
||||
expect(container.textContent).toContain('4');
|
||||
expect(container.querySelector('[data-testid="catalog-filters"]')?.textContent).toBe('catalog-filters');
|
||||
expect(container.querySelector('[data-testid="catalog-search"]')?.textContent).toBe('catalog-search');
|
||||
|
||||
const selects = Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
|
||||
expect(selects).toHaveLength(4);
|
||||
|
||||
await changeSelect(selects[0]!, 'replyCount');
|
||||
await changeSelect(selects[1]!, 'Large');
|
||||
await changeSelect(selects[2]!, 'On');
|
||||
await changeSelect(selects[3]!, 'nsfw');
|
||||
await clickButton('refresh');
|
||||
|
||||
expect(testState.setSortTypeMock).toHaveBeenCalledWith('replyCount');
|
||||
expect(testState.setImageSizeMock).toHaveBeenCalledWith('Large');
|
||||
expect(testState.setShowOPCommentMock).toHaveBeenCalledWith(true);
|
||||
expect(testState.setFilterMock).toHaveBeenCalledWith('nsfw');
|
||||
expect(testState.resetMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders thread actions and post stats, then updates, auto-alerts, and scrolls to the bottom', async () => {
|
||||
testState.commentsByCid = {
|
||||
'comment-1': {
|
||||
cid: 'comment-1',
|
||||
closed: true,
|
||||
number: 99,
|
||||
pinned: true,
|
||||
postCid: 'comment-1',
|
||||
replyCount: 9,
|
||||
},
|
||||
};
|
||||
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/mu/thread/comment-1');
|
||||
|
||||
const tooltips = Array.from(container.querySelectorAll<HTMLElement>('[data-testid="tooltip"]'));
|
||||
expect(tooltips.map((tooltip) => tooltip.dataset.content)).toEqual(['Replies', 'Links', 'pagination.pageLabel']);
|
||||
expect(tooltips.map((tooltip) => tooltip.textContent)).toEqual(['9', '3', '7']);
|
||||
expect(container.textContent).toContain('Sticky /');
|
||||
expect(container.textContent).toContain('Closed /');
|
||||
|
||||
await clickButton('bottom');
|
||||
await clickButton('update');
|
||||
await clickButton('Auto');
|
||||
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ behavior: 'instant', top: 2400 });
|
||||
expect(testState.resetMock).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('posts_auto_update_info');
|
||||
});
|
||||
|
||||
it('renders mobile mod-queue controls and clamps alert threshold updates', async () => {
|
||||
testState.alertThresholdValue = 60;
|
||||
|
||||
await renderWithRoute(createElement(MobileBoardButtons), '/mod/queue');
|
||||
|
||||
const returnLink = container.querySelector<HTMLAnchorElement>('a[href="/mod"]');
|
||||
expect(returnLink?.getAttribute('href')).toBe('/mod');
|
||||
|
||||
const thresholdInput = container.querySelector<HTMLInputElement>('input[type="number"]');
|
||||
const selects = Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
|
||||
expect(thresholdInput).toBeTruthy();
|
||||
expect(selects).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
if (thresholdInput) {
|
||||
setTrackedInputValue(thresholdInput, '0');
|
||||
thresholdInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
thresholdInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
expect(testState.setAlertThresholdMock).toHaveBeenCalledWith(1, 'minutes');
|
||||
|
||||
testState.setAlertThresholdMock.mockClear();
|
||||
await changeSelect(selects[0]!, 'hours');
|
||||
await changeSelect(selects[1]!, 'feed');
|
||||
await clickButton('refresh');
|
||||
|
||||
expect(testState.setAlertThresholdMock).toHaveBeenCalledWith(1, 'hours');
|
||||
expect(testState.setViewModeMock).toHaveBeenCalledWith('feed');
|
||||
expect(testState.resetMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
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 BoardPagination from '../board-pagination';
|
||||
|
||||
(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(() => ({
|
||||
enableInfiniteScroll: false,
|
||||
navigateMock: vi.fn(),
|
||||
setEnableInfiniteScrollMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => (values?.page ? `${key}:${values.page}` : key),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => testState.navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../stores/use-feed-view-settings-store', () => ({
|
||||
default: (selector: (state: { enableInfiniteScroll: boolean; setEnableInfiniteScroll: typeof testState.setEnableInfiniteScrollMock }) => unknown) =>
|
||||
selector({
|
||||
enableInfiniteScroll: testState.enableInfiniteScroll,
|
||||
setEnableInfiniteScroll: testState.setEnableInfiniteScrollMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../style-selector/style-selector', () => ({
|
||||
default: () => createElement('div', { 'data-testid': 'style-selector' }, 'style-selector'),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const renderPagination = (element: React.ReactElement) => {
|
||||
act(() => {
|
||||
root.render(createElement(MemoryRouter, {}, element));
|
||||
});
|
||||
};
|
||||
|
||||
describe('BoardPagination', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.enableInfiniteScroll = false;
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders standard pagination links and previous or next navigation controls', async () => {
|
||||
renderPagination(createElement(BoardPagination, { basePath: '/mu', currentPage: 2, totalPages: 3 }));
|
||||
|
||||
const pageLinks = Array.from(container.querySelectorAll('a'));
|
||||
expect(pageLinks.map((link) => link.textContent)).toEqual(['1', '3']);
|
||||
expect(pageLinks[0]?.getAttribute('href')).toBe('/mu');
|
||||
expect(pageLinks[1]?.getAttribute('href')).toBe('/mu/3');
|
||||
expect(container.querySelector('[aria-current="page"]')?.textContent).toBe('2');
|
||||
|
||||
await act(async () => {
|
||||
const buttons = Array.from(container.querySelectorAll('button'));
|
||||
buttons[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
buttons[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenNthCalledWith(1, '/mu');
|
||||
expect(testState.navigateMock).toHaveBeenNthCalledWith(2, '/mu/3');
|
||||
});
|
||||
|
||||
it('shows the footer pagelist, catalog links, and enables infinite scroll from the all shortcut', async () => {
|
||||
renderPagination(createElement(BoardPagination, { basePath: '/mu', currentPage: 1, footerStyle: true, totalPages: 3 }));
|
||||
|
||||
expect(container.querySelector('[data-testid="style-selector"]')?.textContent).toBe('style-selector');
|
||||
expect(container.textContent).toContain('catalog');
|
||||
expect(container.textContent).toContain('archive');
|
||||
|
||||
const allLink = Array.from(container.querySelectorAll<HTMLElement>('[role="button"]')).find((element) => element.textContent === 'all');
|
||||
expect(allLink).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
allLink?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
|
||||
});
|
||||
|
||||
expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true);
|
||||
|
||||
await act(async () => {
|
||||
const nextButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'next');
|
||||
nextButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/2');
|
||||
});
|
||||
|
||||
it('hides the footer pagelist for multiboards or when infinite scroll is already enabled', () => {
|
||||
testState.enableInfiniteScroll = true;
|
||||
renderPagination(createElement(BoardPagination, { basePath: '/all', currentPage: 1, footerStyle: true, isMultiboard: true, totalPages: 5 }));
|
||||
|
||||
expect(container.querySelector('[data-testid="style-selector"]')).toBeTruthy();
|
||||
expect(container.textContent).not.toContain('catalog');
|
||||
expect(container.textContent).not.toContain('archive');
|
||||
});
|
||||
|
||||
it('returns nothing for single-page non-footer pagination', () => {
|
||||
renderPagination(createElement(BoardPagination, { basePath: '/mu', currentPage: 1, totalPages: 1 }));
|
||||
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
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 PostPage, { Post } from '../post';
|
||||
|
||||
(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 = {
|
||||
cid?: string;
|
||||
content?: string;
|
||||
error?: Error;
|
||||
locked?: boolean;
|
||||
number?: number;
|
||||
parentCid?: string;
|
||||
pinned?: boolean;
|
||||
postCid?: string;
|
||||
replyCount?: number;
|
||||
replies?: unknown[];
|
||||
state?: string;
|
||||
subplebbitAddress?: string;
|
||||
timestamp?: number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
cachedComments: {} as Record<string, TestComment>,
|
||||
commentsByCid: {} as Record<string, TestComment>,
|
||||
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
|
||||
editedCommentsByCid: {} as Record<string, TestComment | undefined>,
|
||||
isMobile: false,
|
||||
navigateMock: vi.fn(),
|
||||
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
|
||||
subplebbit: {
|
||||
error: undefined as Error | undefined,
|
||||
shortAddress: 'music-posting.eth',
|
||||
title: '/mu/ - Music',
|
||||
},
|
||||
subplebbitSnapshot: {
|
||||
roles: {
|
||||
'0xmod': { role: 'admin' },
|
||||
},
|
||||
} as { roles?: Record<string, unknown> },
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => testState.navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
|
||||
useEditedComment: ({ comment }: { comment?: TestComment }) => ({
|
||||
editedComment: comment?.cid ? testState.editedCommentsByCid[comment.cid] : undefined,
|
||||
}),
|
||||
useSubplebbit: () => testState.subplebbit,
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({
|
||||
default: (selector: (state: { comments: typeof testState.cachedComments }) => unknown) =>
|
||||
selector({
|
||||
comments: testState.cachedComments,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-stable-subplebbit', () => ({
|
||||
useSubplebbitField: (_address: string | undefined, selector: (subplebbit: typeof testState.subplebbitSnapshot) => unknown) => selector(testState.subplebbitSnapshot),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
||||
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../hooks/use-directories')>('../../../hooks/use-directories');
|
||||
return {
|
||||
...actual,
|
||||
useDirectories: () => testState.directories,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||
default: () => testState.isMobile,
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/error-display/error-display', () => ({
|
||||
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/footer', () => ({
|
||||
PageFooterDesktop: ({ firstRow, styleRow }: { firstRow: React.ReactNode; styleRow: React.ReactNode }) =>
|
||||
createElement('div', { 'data-testid': 'page-footer-desktop' }, firstRow, styleRow),
|
||||
ThreadFooterFirstRow: ({
|
||||
isThreadClosed,
|
||||
postCid,
|
||||
subplebbitAddress,
|
||||
threadNumber,
|
||||
}: {
|
||||
isThreadClosed: boolean;
|
||||
postCid: string;
|
||||
subplebbitAddress: string;
|
||||
threadNumber?: number;
|
||||
}) => createElement('div', { 'data-testid': 'thread-footer-first-row' }, `${postCid}:${threadNumber}:${subplebbitAddress}:${String(isThreadClosed)}`),
|
||||
ThreadFooterMobile: ({
|
||||
isThreadClosed,
|
||||
postCid,
|
||||
subplebbitAddress,
|
||||
threadNumber,
|
||||
}: {
|
||||
isThreadClosed: boolean;
|
||||
postCid: string;
|
||||
subplebbitAddress: string;
|
||||
threadNumber?: number;
|
||||
}) => createElement('div', { 'data-testid': 'thread-footer-mobile' }, `${postCid}:${threadNumber}:${subplebbitAddress}:${String(isThreadClosed)}`),
|
||||
ThreadFooterStyleRow: () => createElement('div', { 'data-testid': 'thread-footer-style-row' }, 'thread-footer-style-row'),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/post-desktop', () => ({
|
||||
default: ({ post, roles, targetReplyCid }: { post?: TestComment; roles?: Record<string, unknown>; targetReplyCid?: string }) =>
|
||||
createElement('div', { 'data-testid': 'post-desktop' }, `${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/post-mobile', () => ({
|
||||
default: ({ post, roles, targetReplyCid }: { post?: TestComment; roles?: Record<string, unknown>; targetReplyCid?: string }) =>
|
||||
createElement('div', { 'data-testid': 'post-mobile' }, `${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
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 renderPostPage = async (initialEntry: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: [initialEntry] },
|
||||
createElement(
|
||||
Routes,
|
||||
{},
|
||||
createElement(Route, { path: '/all/thread/:commentCid', element: createElement(PostPage) }),
|
||||
createElement(Route, { path: '/:boardIdentifier/thread/:commentCid', element: createElement(PostPage) }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
await flushEffects();
|
||||
};
|
||||
|
||||
describe('Post', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.cachedComments = {};
|
||||
testState.commentsByCid = {};
|
||||
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
|
||||
testState.editedCommentsByCid = {};
|
||||
testState.isMobile = false;
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
testState.subplebbit = {
|
||||
error: undefined,
|
||||
shortAddress: 'music-posting.eth',
|
||||
title: '/mu/ - Music',
|
||||
};
|
||||
testState.subplebbitSnapshot = {
|
||||
roles: {
|
||||
'0xmod': { role: 'admin' },
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
});
|
||||
document.title = 'before';
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders edited posts through the desktop and mobile presenters with stable role data', async () => {
|
||||
testState.editedCommentsByCid = {
|
||||
'post-1': { cid: 'edited-post', subplebbitAddress: 'music-posting.eth' },
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(Post, { post: { cid: 'post-1', subplebbitAddress: 'music-posting.eth' } }));
|
||||
});
|
||||
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('edited-post:none:1');
|
||||
|
||||
testState.isMobile = true;
|
||||
await act(async () => {
|
||||
root.render(createElement(Post, { post: { cid: 'post-2', subplebbitAddress: 'music-posting.eth' } }));
|
||||
});
|
||||
expect(container.querySelector('[data-testid="post-mobile"]')?.textContent).toBe('post-2:none:1');
|
||||
});
|
||||
|
||||
it('hydrates thread pages from cached feed data, sets the document title, and renders thread footers', async () => {
|
||||
testState.commentsByCid = {
|
||||
'cached-cid': {
|
||||
cid: 'cached-cid',
|
||||
state: 'updating',
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
},
|
||||
};
|
||||
testState.cachedComments = {
|
||||
'cached-cid': {
|
||||
cid: 'cached-cid',
|
||||
content: 'cached body',
|
||||
number: 42,
|
||||
replyCount: 0,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
title: 'Cached thread',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPostPage('/mu/thread/cached-cid');
|
||||
|
||||
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('cached-cid:none:1');
|
||||
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false');
|
||||
expect(container.querySelector('[data-testid="thread-footer-mobile"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false');
|
||||
expect(document.title).toBe('/mu/ - Cached thread... - 5chan');
|
||||
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
|
||||
it('redirects thread routes whose fetched comment belongs to a different board', async () => {
|
||||
testState.commentsByCid = {
|
||||
'comment-1': {
|
||||
cid: 'comment-1',
|
||||
postCid: 'comment-1',
|
||||
subplebbitAddress: 'other.eth',
|
||||
title: 'Other board thread',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPostPage('/mu/thread/comment-1');
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true });
|
||||
});
|
||||
|
||||
it('renders reply pages using the root post, highlights the reply target, and shows thread errors', async () => {
|
||||
testState.commentsByCid = {
|
||||
'reply-cid': {
|
||||
cid: 'reply-cid',
|
||||
parentCid: 'root-cid',
|
||||
postCid: 'root-cid',
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
},
|
||||
'root-cid': {
|
||||
cid: 'root-cid',
|
||||
error: new Error('thread failed'),
|
||||
locked: true,
|
||||
number: 99,
|
||||
replies: [],
|
||||
replyCount: 4,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
title: 'Root thread',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPostPage('/mu/thread/reply-cid');
|
||||
|
||||
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('root-cid:reply-cid:1');
|
||||
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('root-cid:99:music-posting.eth:true');
|
||||
expect(container.textContent).toContain('thread failed');
|
||||
});
|
||||
|
||||
it('shows missing-comment and board-load errors when no thread can be resolved', async () => {
|
||||
testState.commentsByCid = {
|
||||
'missing-cid': {
|
||||
error: new Error('missing comment'),
|
||||
},
|
||||
};
|
||||
testState.subplebbit = {
|
||||
error: new Error('board failed'),
|
||||
shortAddress: 'music-posting.eth',
|
||||
title: '/mu/ - Music',
|
||||
};
|
||||
|
||||
await renderPostPage('/mu/thread/missing-cid');
|
||||
|
||||
expect(Array.from(container.querySelectorAll('[data-testid="error-display"]')).map((node) => node.textContent)).toEqual(['board failed', 'missing comment']);
|
||||
expect(container.querySelector('[data-testid="thread-footer-first-row"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user