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