test: cover board, catalog, and publish flows

Board browsing, catalog filtering, post publishing, and reply publishing had little or no honest whole-repo coverage, which let route-level regressions and duplicated form side effects slip through. This pass adds targeted tests for those user flows and fixes the duplicate `PostFormTable` mounting bug they exposed.
This commit is contained in:
plebeius
2026-03-08 14:29:47 +08:00
parent 76eb4b537e
commit 1fbf715aa9
12 changed files with 2455 additions and 37 deletions
@@ -2,7 +2,6 @@ import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AccountDataEditor from '../account-data-editor';
(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>;
@@ -75,6 +74,9 @@ vi.mock('ace-builds/src-noconflict/theme-monokai', () => ({}));
let root: Root;
let container: HTMLDivElement;
let AccountDataEditor: React.ComponentType;
const queryEditor = () => container.querySelector<HTMLTextAreaElement>('[data-testid="ace-editor"]') ?? container.querySelector<HTMLTextAreaElement>('textarea');
const flushEffects = async (count = 10) => {
for (let i = 0; i < count; i += 1) {
@@ -85,17 +87,17 @@ const flushEffects = async (count = 10) => {
};
const waitForEditor = async () => {
for (let i = 0; i < 50; i += 1) {
for (let i = 0; i < 200; i += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 5));
});
await flushEffects(2);
if (container.querySelector('[data-testid="ace-editor"]')) {
if (queryEditor()) {
return;
}
}
expect(container.querySelector('[data-testid="ace-editor"]')).toBeTruthy();
expect(queryEditor()).toBeTruthy();
};
const clickButton = async (label: string) => {
@@ -108,7 +110,7 @@ const clickButton = async (label: string) => {
};
const changeEditorValue = async (value: string) => {
const editor = container.querySelector<HTMLTextAreaElement>('[data-testid="ace-editor"]');
const editor = queryEditor();
expect(editor).toBeTruthy();
await act(async () => {
@@ -128,8 +130,10 @@ const renderEditor = () => {
};
describe('AccountDataEditor', () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
AccountDataEditor = (await import('../account-data-editor')).default;
testState.account = { id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } };
testState.alertMock.mockReset();
testState.buildEditableAccountJsonMock.mockReturnValue(DEFAULT_JSON);
@@ -177,7 +181,7 @@ describe('AccountDataEditor', () => {
await waitForEditor();
expect(container.textContent).not.toContain('loading_editor');
expect(container.querySelector('[data-testid="ace-editor"]')).toBeTruthy();
expect(queryEditor()).toBeTruthy();
await clickButton('return_to_settings');
@@ -189,14 +193,14 @@ describe('AccountDataEditor', () => {
await clickButton('continue');
await waitForEditor();
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="ace-editor"]')?.value).toBe(DEFAULT_JSON);
expect(queryEditor()?.value).toBe(DEFAULT_JSON);
await changeEditorValue('{"account":{"name":"changed"}}');
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="ace-editor"]')?.value).toBe('{"account":{"name":"changed"}}');
expect(queryEditor()?.value).toBe('{"account":{"name":"changed"}}');
await clickButton('reset_changes');
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="ace-editor"]')?.value).toBe(DEFAULT_JSON);
expect(queryEditor()?.value).toBe(DEFAULT_JSON);
});
it('alerts on invalid JSON without attempting to save', async () => {
+363
View File
@@ -0,0 +1,363 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Board, { type BoardProps } from '../board';
(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;
pinned?: boolean;
subplebbitAddress?: string;
deleted?: boolean;
postCid?: string;
removed?: boolean;
state?: string;
timestamp?: number;
};
const testState = vi.hoisted(() => ({
account: { subscriptions: [] as string[] },
accountComments: [] as TestComment[],
accountSubplebbitAddresses: [] as string[],
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
directoryByAddress: {
'music-posting.eth': {
address: 'music-posting.eth',
features: { postsPerPage: 2 },
},
} as Record<string, { address: string; features?: Record<string, unknown> }>,
feed: [] as TestComment[],
feedStateString: 'syncing',
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
hasMore: false,
loadMoreMock: vi.fn(),
pageSizes: {
guiPostsPerPage: 2,
infiniteFeedPostsPerPage: 2,
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
},
resetMock: vi.fn(),
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
setEnableInfiniteScrollMock: vi.fn(),
setResetFunctionMock: vi.fn(),
subplebbit: {
error: undefined as Error | undefined,
shortAddress: 'music-posting.eth',
state: 'ready',
title: '/mu/ - Music',
},
subplebbitSnapshot: {
shortAddress: 'music-posting.eth',
title: '/mu/ - Music',
} as { shortAddress?: string; title?: string },
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useAccountComments: () => ({ accountComments: testState.accountComments }),
useFeed: () => ({
feed: testState.feed,
hasMore: testState.hasMore,
loadMore: testState.loadMoreMock,
reset: testState.resetMock,
}),
useSubplebbit: () => testState.subplebbit,
}));
vi.mock('../../../hooks/use-stable-subplebbit', () => ({
useSubplebbitField: (_address: string | undefined, selector: (subplebbit: typeof testState.subplebbitSnapshot) => unknown) => selector(testState.subplebbitSnapshot),
}));
vi.mock('react-virtuoso', () => ({
Virtuoso: React.forwardRef(
(
{
components,
data = [],
endReached,
itemContent,
}: {
components?: { Footer?: React.ComponentType };
data?: TestComment[];
endReached?: ((index: number) => void) | undefined;
itemContent: (index: number, item: TestComment) => React.ReactNode;
},
ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>,
) => {
React.useImperativeHandle(ref, () => ({
getState: (cb) => cb({ ranges: [0], scrollTop: 42 }),
}));
return createElement(
'div',
{ 'data-testid': 'virtuoso' },
data.map((item, index) => createElement('div', { key: item.cid }, itemContent(index, item))),
endReached ? createElement('button', { 'data-testid': 'end-reached', onClick: () => endReached(data.length) }, 'end-reached') : null,
components?.Footer ? createElement(components.Footer) : null,
);
},
),
}));
vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({
useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses,
}));
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
useDirectoryAddresses: () => testState.directories.map((entry) => entry.address),
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined),
}));
vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({
useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses,
}));
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
}));
vi.mock('../../../hooks/use-state-string', () => ({
useFeedStateString: () => testState.feedStateString,
}));
vi.mock('../../../stores/use-feed-reset-store', () => ({
default: (selector: (state: { setResetFunction: typeof testState.setResetFunctionMock }) => unknown) =>
selector({
setResetFunction: testState.setResetFunctionMock,
}),
}));
vi.mock('../../../stores/use-feed-view-settings-store', () => ({
default: (selector: (state: { enableInfiniteScroll: boolean; setEnableInfiniteScroll: typeof testState.setEnableInfiniteScrollMock }) => unknown) =>
selector({
enableInfiniteScroll: false,
setEnableInfiniteScroll: testState.setEnableInfiniteScrollMock,
}),
}));
vi.mock('../../../hooks/use-board-feed-page-size', () => ({
useBoardFeedPageSize: () => testState.pageSizes,
}));
vi.mock('../../../components/error-display/error-display', () => ({
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'),
}));
vi.mock('../../../components/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
}));
vi.mock('../../../components/board-pagination', () => ({
default: ({ basePath, currentPage, totalPages }: { basePath: string; currentPage: number; totalPages: number }) =>
createElement('div', { 'data-testid': 'board-pagination' }, `${basePath}:${currentPage}:${totalPages}`),
}));
vi.mock('../../../components/board-buttons/board-buttons', () => ({
CatalogButton: ({ address }: { address?: string }) => createElement('div', { 'data-testid': 'catalog-button' }, address || 'catalog'),
}));
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),
}));
vi.mock('../../post', () => ({
Post: ({ post }: { post?: TestComment }) => createElement('div', { 'data-testid': 'post' }, post?.cid || 'missing-post'),
}));
vi.mock('../../../lib/snow', () => ({
shouldShowSnow: () => false,
}));
let container: HTMLDivElement;
let latestLocation = '';
let root: Root;
const LocationProbe = () => {
const location = useLocation();
React.useLayoutEffect(() => {
latestLocation = location.pathname;
}, [location.pathname]);
return null;
};
const flushEffects = async (count = 5) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
};
const renderBoard = async ({ boardProps, initialEntry, routePath }: { boardProps?: BoardProps; initialEntry: string; routePath: string }) => {
latestLocation = initialEntry;
await act(async () => {
root.render(
createElement(
MemoryRouter,
{ initialEntries: [initialEntry] },
createElement(
Routes,
{},
createElement(Route, { path: routePath, element: createElement(Board, boardProps) }),
createElement(Route, { path: '*', element: createElement(Board, boardProps) }),
),
createElement(LocationProbe),
),
);
});
await flushEffects();
};
describe('Board', () => {
beforeEach(() => {
vi.clearAllMocks();
latestLocation = '';
testState.account = { subscriptions: [] };
testState.accountComments = [];
testState.accountSubplebbitAddresses = [];
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
testState.directoryByAddress = {
'music-posting.eth': {
address: 'music-posting.eth',
features: { postsPerPage: 2 },
},
};
testState.feed = [];
testState.feedStateString = 'syncing';
testState.filteredDirectoryAddresses = ['music-posting.eth'];
testState.hasMore = false;
testState.pageSizes = {
guiPostsPerPage: 2,
infiniteFeedPostsPerPage: 2,
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
};
testState.resolvedSubplebbitAddress = 'music-posting.eth';
testState.subplebbit = {
error: undefined,
shortAddress: 'music-posting.eth',
state: 'ready',
title: '/mu/ - Music',
};
testState.subplebbitSnapshot = {
shortAddress: 'music-posting.eth',
title: '/mu/ - Music',
};
testState.loadMoreMock.mockReset();
testState.resetMock.mockReset();
testState.setEnableInfiniteScrollMock.mockReset();
testState.setResetFunctionMock.mockReset();
document.title = 'before';
Object.defineProperty(window, 'scrollTo', {
configurable: true,
value: vi.fn(),
writable: true,
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders the current page feed, inserts recent account comments, and wires footer actions', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.feed = [
{ cid: 'pinned-post', pinned: true, subplebbitAddress: 'music-posting.eth' },
{ cid: 'older-post', subplebbitAddress: 'music-posting.eth' },
{ cid: 'oldest-post', subplebbitAddress: 'music-posting.eth' },
];
testState.accountComments = [
{
cid: 'fresh-post',
postCid: 'fresh-post',
state: 'succeeded',
subplebbitAddress: 'music-posting.eth',
timestamp: currentTimestamp,
},
];
testState.hasMore = true;
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(document.title).toBe('/mu/ - 5chan');
expect(testState.setResetFunctionMock).toHaveBeenCalledWith(testState.resetMock);
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['pinned-post', 'fresh-post']);
expect(container.querySelector('[data-testid="board-pagination"]')?.textContent).toBe('/mu:1:2');
await act(async () => {
const topButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'top');
topButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(window.scrollTo).toHaveBeenCalledWith({ behavior: 'instant', left: 0, top: 0 });
await act(async () => {
const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh');
refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
const loadMoreButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'load_more');
loadMoreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.resetMock).toHaveBeenCalledTimes(2);
expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true);
});
it('redirects oversized board pages back to the last available page', async () => {
testState.feed = [
{ cid: 'first-post', subplebbitAddress: 'music-posting.eth' },
{ cid: 'second-post', subplebbitAddress: 'music-posting.eth' },
{ cid: 'third-post', subplebbitAddress: 'music-posting.eth' },
];
await renderBoard({ initialEntry: '/mu/4', routePath: '/:boardIdentifier/*' });
expect(latestLocation).toBe('/mu/2');
});
it('canonicalizes multiboard paths and shows the subscriptions empty state', async () => {
testState.account = { subscriptions: [] };
testState.filteredDirectoryAddresses = [];
await renderBoard({
boardProps: { viewType: 'subs' },
initialEntry: '/subs/9',
routePath: '/subs/*',
});
expect(latestLocation).toBe('/subs');
expect(container.textContent).toContain('not_subscribed_to_any_board');
});
it('surfaces board load errors when the feed is empty', async () => {
testState.subplebbit = {
error: new Error('board failed'),
shortAddress: 'music-posting.eth',
state: 'failed',
title: '/mu/ - Music',
};
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(container.querySelector('[data-testid="error-display"]')?.textContent).toBe('board failed');
expect(container.textContent).toContain('failed');
});
});
@@ -0,0 +1,370 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Catalog, { type CatalogProps } from '../catalog';
(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;
title?: string;
pinned?: boolean;
subplebbitAddress?: string;
deleted?: boolean;
postCid?: string;
removed?: boolean;
state?: string;
timestamp?: number;
};
type FilterItem = {
color?: string;
count: number;
enabled: boolean;
filteredCids: Set<string>;
hide: boolean;
text: string;
top: boolean;
};
const testState = vi.hoisted(() => ({
account: { subscriptions: [] as string[] },
accountComments: [] as TestComment[],
clearMatchedFiltersMock: vi.fn(),
directoryByAddress: {
'music-posting.eth': {
address: 'music-posting.eth',
features: { postsPerPage: 2 },
},
} as Record<string, { address: string; features?: Record<string, unknown> }>,
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
feed: [] as TestComment[],
filterItems: [] as FilterItem[],
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
hasMore: false,
incrementFilterCountMock: vi.fn(),
loadMoreMock: vi.fn(),
pageSizes: {
guiPostsPerPage: 2,
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
},
resetMock: vi.fn(),
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
searchText: '',
setCurrentSubplebbitAddressMock: vi.fn(),
setMatchedFilterMock: vi.fn(),
setResetFunctionMock: vi.fn(),
sortType: 'new' as 'active' | 'new',
subplebbit: {
error: undefined as Error | undefined,
shortAddress: 'music-posting.eth',
state: 'ready',
title: '/mu/ - Music',
},
}));
function getCatalogFiltersState() {
return {
clearMatchedFilters: testState.clearMatchedFiltersMock,
filterItems: testState.filterItems,
incrementFilterCount: testState.incrementFilterCountMock,
searchText: testState.searchText,
setCurrentSubplebbitAddress: testState.setCurrentSubplebbitAddressMock,
setMatchedFilter: testState.setMatchedFilterMock,
};
}
function useCatalogFiltersStoreMock<T>(selector?: (state: ReturnType<typeof getCatalogFiltersState>) => T) {
const state = getCatalogFiltersState();
return selector ? selector(state) : (state as T);
}
useCatalogFiltersStoreMock.getState = getCatalogFiltersState;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useAccountComments: () => ({ accountComments: testState.accountComments }),
useFeed: (options: { filter?: { filter: (comment: TestComment) => boolean } }) => ({
feed: options.filter ? testState.feed.filter((comment) => options.filter?.filter(comment)) : testState.feed,
hasMore: testState.hasMore,
loadMore: testState.loadMoreMock,
reset: testState.resetMock,
}),
useSubplebbit: () => testState.subplebbit,
}));
vi.mock('react-virtuoso', () => ({
Virtuoso: React.forwardRef(
(
{
components,
data = [],
endReached,
itemContent,
}: {
components?: { Footer?: React.ComponentType };
data?: Array<TestComment[]>;
endReached?: ((index: number) => void) | undefined;
itemContent: (index: number, item: TestComment[]) => React.ReactNode;
},
ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>,
) => {
React.useImperativeHandle(ref, () => ({
getState: (cb) => cb({ ranges: [0], scrollTop: 24 }),
}));
return createElement(
'div',
{ 'data-testid': 'virtuoso' },
data.map((row, index) => createElement('div', { key: `row-${index}` }, itemContent(index, row))),
endReached ? createElement('button', { 'data-testid': 'end-reached', onClick: () => endReached(data.length) }, 'end-reached') : null,
components?.Footer ? createElement(components.Footer) : null,
);
},
),
}));
vi.mock('../../../hooks/use-catalog-feed-rows', () => ({
default: (_columnCount: number, processedFeed: TestComment[]) => processedFeed.map((comment) => [comment]),
}));
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined),
}));
vi.mock('../../../hooks/use-board-feed-page-size', () => ({
useBoardFeedPageSize: () => testState.pageSizes,
}));
vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({
useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses,
}));
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
}));
vi.mock('../../../hooks/use-state-string', () => ({
useFeedStateString: () => 'loading_feed',
}));
vi.mock('../../../hooks/use-window-width', () => ({
default: () => 900,
}));
vi.mock('../../../stores/use-catalog-style-store', () => ({
default: () => ({
imageSize: 'Small',
}),
}));
vi.mock('../../../stores/use-feed-reset-store', () => ({
default: (selector: (state: { setResetFunction: typeof testState.setResetFunctionMock }) => unknown) =>
selector({
setResetFunction: testState.setResetFunctionMock,
}),
}));
vi.mock('../../../stores/use-sorting-store', () => ({
default: () => ({
sortType: testState.sortType,
}),
}));
vi.mock('../../../stores/use-catalog-filters-store', () => ({
default: useCatalogFiltersStoreMock,
}));
vi.mock('../../../components/catalog-row', () => ({
default: ({ row }: { row: TestComment[] }) => createElement('div', { 'data-testid': 'catalog-row' }, `row:${row.map((comment) => comment.cid).join(',')}`),
}));
vi.mock('../../../components/footer', () => ({
CatalogFooterFirstRow: ({ subplebbitAddress }: { subplebbitAddress?: string }) =>
createElement('div', { 'data-testid': 'catalog-first-row' }, subplebbitAddress || 'multi'),
PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'catalog-footer-desktop' }, firstRow),
}));
vi.mock('../../../components/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
}));
vi.mock('../../../components/error-display/error-display', () => ({
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'),
}));
vi.mock('../../../lib/utils/pattern-utils', () => ({
commentMatchesPattern: (comment: TestComment, pattern: string) => {
const loweredPattern = pattern.toLowerCase();
return `${comment.title || ''} ${comment.content || ''}`.toLowerCase().includes(loweredPattern);
},
}));
vi.mock('../../../lib/utils/catalog-sort', () => ({
sortCatalogFeedForDisplay: (feed: TestComment[]) => feed,
}));
let container: HTMLDivElement;
let latestLocation = '';
let root: Root;
const LocationProbe = () => {
const location = useLocation();
React.useLayoutEffect(() => {
latestLocation = location.pathname;
}, [location.pathname]);
return null;
};
const flushEffects = async (count = 5) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
};
const renderCatalog = async ({ catalogProps, initialEntry, routePath }: { catalogProps?: CatalogProps; initialEntry: string; routePath: string }) => {
latestLocation = initialEntry;
await act(async () => {
root.render(
createElement(
MemoryRouter,
{ initialEntries: [initialEntry] },
createElement(
Routes,
{},
createElement(Route, { path: routePath, element: createElement(Catalog, catalogProps) }),
createElement(Route, { path: '*', element: createElement(Catalog, catalogProps) }),
),
createElement(LocationProbe),
),
);
});
await flushEffects();
};
describe('Catalog', () => {
beforeEach(() => {
vi.clearAllMocks();
latestLocation = '';
testState.account = { subscriptions: [] };
testState.accountComments = [];
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
testState.directoryByAddress = {
'music-posting.eth': {
address: 'music-posting.eth',
features: { postsPerPage: 2 },
},
};
testState.feed = [];
testState.filterItems = [];
testState.filteredDirectoryAddresses = ['music-posting.eth'];
testState.hasMore = false;
testState.pageSizes = {
guiPostsPerPage: 2,
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
};
testState.resolvedSubplebbitAddress = 'music-posting.eth';
testState.searchText = '';
testState.sortType = 'new';
testState.subplebbit = {
error: undefined,
shortAddress: 'music-posting.eth',
state: 'ready',
title: '/mu/ - Music',
};
testState.clearMatchedFiltersMock.mockReset();
testState.incrementFilterCountMock.mockReset();
testState.loadMoreMock.mockReset();
testState.resetMock.mockReset();
testState.setCurrentSubplebbitAddressMock.mockReset();
testState.setMatchedFilterMock.mockReset();
testState.setResetFunctionMock.mockReset();
document.title = 'before';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('applies catalog filters, promotes top matches, and clears board filter state on unmount', async () => {
testState.feed = [
{ cid: 'boring-post', title: 'plain talk', content: 'nothing special', subplebbitAddress: 'music-posting.eth' },
{ cid: 'hidden-post', title: 'cats and spoilers', content: 'spoiler content', subplebbitAddress: 'music-posting.eth' },
{ cid: 'top-post', title: 'cats forever', content: 'hello world', subplebbitAddress: 'music-posting.eth' },
];
testState.filterItems = [
{ count: 0, enabled: true, filteredCids: new Set(), hide: true, text: 'spoiler', top: false },
{ color: 'red', count: 0, enabled: true, filteredCids: new Set(), hide: false, text: 'cats', top: true },
];
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(document.title).toBe('/mu/ - catalog - 5chan');
expect(testState.setCurrentSubplebbitAddressMock).toHaveBeenCalledWith('music-posting.eth');
expect(testState.clearMatchedFiltersMock).toHaveBeenCalled();
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:top-post', 'row:boring-post']);
expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(0, 'hidden-post', 'music-posting.eth');
expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(1, 'top-post', 'music-posting.eth');
expect(testState.setMatchedFilterMock).toHaveBeenCalledWith('top-post', 'red');
act(() => root.unmount());
expect(testState.setCurrentSubplebbitAddressMock).toHaveBeenLastCalledWith(null);
expect(testState.clearMatchedFiltersMock).toHaveBeenCalledTimes(3);
root = createRoot(container);
});
it('canonicalizes multiboard catalog paths and keeps load-more wired for infinite scrolling', async () => {
testState.feed = [{ cid: 'all-post', title: 'one', subplebbitAddress: 'music-posting.eth' }];
testState.hasMore = true;
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog/7',
routePath: '/all/*',
});
expect(latestLocation).toBe('/all/catalog');
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="end-reached"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.loadMoreMock).toHaveBeenCalledTimes(1);
});
it('shows the empty subscriptions state when there are no subscribed boards to browse', async () => {
testState.account = { subscriptions: [] };
await renderCatalog({
catalogProps: { viewType: 'subs' },
initialEntry: '/subs/catalog',
routePath: '/subs/*',
});
expect(container.textContent).toContain('not_subscribed_to_any_board');
expect(container.querySelector('[data-testid="catalog-first-row"]')?.textContent).toBe('music-posting.eth');
});
});
+160
View File
@@ -0,0 +1,160 @@
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 Home from '../home';
(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(() => ({
closeDirectoryModalMock: vi.fn(),
directories: [] as Array<{ address: string; title?: string }>,
directoryAddresses: [] as string[],
navigateMock: vi.fn(),
subplebbits: {} as Record<string, unknown>,
subplebbitsStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number }>,
}));
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey }: { i18nKey: string }) => createElement('span', { 'data-testid': `trans-${i18nKey}` }, i18nKey),
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', () => ({
useSubplebbits: () => ({ subplebbits: testState.subplebbits }),
}));
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
useDirectoryAddresses: () => testState.directoryAddresses,
}));
vi.mock('../../../hooks/use-subplebbits-stats', () => ({
SubplebbitStatsCollector: ({ subplebbitAddress }: { subplebbitAddress: string }) =>
createElement('div', { 'data-testid': 'stats-collector', 'data-address': subplebbitAddress }),
useSubplebbitsStatsStore: (selector: (state: { subplebbitsStats: typeof testState.subplebbitsStats }) => unknown) =>
selector({ subplebbitsStats: testState.subplebbitsStats }),
}));
vi.mock('../../../stores/use-directory-modal-store', () => ({
default: () => ({
closeDirectoryModal: testState.closeDirectoryModalMock,
}),
}));
vi.mock('../boards-list', () => ({
default: ({ multisub }: { multisub: unknown[] }) => createElement('div', { 'data-testid': 'boards-list' }, `boards:${multisub.length}`),
}));
vi.mock('../popular-threads-box', () => ({
default: ({ directories, subplebbits }: { directories: unknown[]; subplebbits: Record<string, unknown> }) =>
createElement('div', { 'data-testid': 'popular-threads-box' }, `popular:${directories.length}:${Object.keys(subplebbits).length}`),
}));
vi.mock('../../../components/site-legal-meta', () => ({
default: () => createElement('div', { 'data-testid': 'site-legal-meta' }, 'site-legal-meta'),
}));
vi.mock('../../../components/disclaimer-modal', () => ({
default: () => createElement('div', { 'data-testid': 'disclaimer-modal' }, 'disclaimer-modal'),
}));
vi.mock('../../../components/directory-modal', () => ({
default: () => createElement('div', { 'data-testid': 'directory-modal' }, 'directory-modal'),
}));
let container: HTMLDivElement;
let root: Root;
const renderHome = () => {
act(() => {
root.render(createElement(MemoryRouter, {}, createElement(Home)));
});
};
describe('Home', () => {
beforeEach(() => {
vi.clearAllMocks();
document.title = 'before';
testState.closeDirectoryModalMock.mockReset();
testState.navigateMock.mockReset();
testState.directories = [
{ address: 'music-posting.eth', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', title: '/g/ - Technology' },
];
testState.directoryAddresses = ['music-posting.eth', 'tech-posting.eth'];
testState.subplebbits = {
'music-posting.eth': { address: 'music-posting.eth' },
'tech-posting.eth': { address: 'tech-posting.eth' },
};
testState.subplebbitsStats = {
'music-posting.eth': { allPostCount: 5, weekActiveUserCount: 2 },
'tech-posting.eth': { allPostCount: 7, weekActiveUserCount: 5 },
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders the home view chrome, child sections, collectors, and aggregated stats', () => {
renderHome();
expect(document.title).toBe('5chan');
expect(container.querySelector('[data-testid="disclaimer-modal"]')?.textContent).toBe('disclaimer-modal');
expect(container.querySelector('[data-testid="directory-modal"]')?.textContent).toBe('directory-modal');
expect(container.querySelector('[data-testid="boards-list"]')?.textContent).toBe('boards:2');
expect(container.querySelector('[data-testid="popular-threads-box"]')?.textContent).toBe('popular:2:2');
expect(container.querySelectorAll('[data-testid="stats-collector"]')).toHaveLength(2);
expect(container.textContent).toContain('total_posts 12');
expect(container.textContent).toContain('current_users 7');
expect(container.textContent).toContain('boards_tracked 2');
expect(container.querySelector('[data-testid="site-legal-meta"]')?.textContent).toBe('site-legal-meta');
});
it('navigates to the canonical board path when the search form is submitted', async () => {
renderHome();
const input = container.querySelector<HTMLInputElement>('input[type="text"]');
const form = container.querySelector('form');
expect(input).toBeTruthy();
expect(form).toBeTruthy();
await act(async () => {
if (input) {
input.value = 'music-posting.eth';
}
form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
expect(testState.navigateMock).toHaveBeenCalledWith('/mu');
});
it('closes the directory modal when the home view unmounts', () => {
renderHome();
expect(testState.closeDirectoryModalMock).not.toHaveBeenCalled();
act(() => root.unmount());
expect(testState.closeDirectoryModalMock).toHaveBeenCalledTimes(1);
root = createRoot(container);
});
});