mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"diff": false,
|
||||
"ignore": {
|
||||
"files": ["**/__tests__/**", "**/*.test.*"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../app';
|
||||
|
||||
(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 ReplyModalShape = {
|
||||
activeCid: string | null;
|
||||
closeModal: ReturnType<typeof vi.fn>;
|
||||
parentNumber: number | null;
|
||||
scrollY: number;
|
||||
showReplyModal: boolean;
|
||||
subplebbitAddress: string | null;
|
||||
threadCid: string | null;
|
||||
threadNumber: number | null;
|
||||
};
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
account: { author: { address: '0x123' } } as unknown,
|
||||
accountComments: {} as Record<number, { subplebbitAddress?: string }>,
|
||||
accountSubplebbitAddresses: [] as string[],
|
||||
closeCreateBoardModalMock: vi.fn(),
|
||||
directories: [
|
||||
{ address: 'music-posting.eth', title: '/mu/ - Music', nsfw: false },
|
||||
{ address: 'tech-posting.eth', title: '/g/ - Technology', nsfw: false },
|
||||
] as Array<{ address: string; title?: string; nsfw?: boolean }>,
|
||||
initSnowMock: vi.fn(),
|
||||
isMobile: false,
|
||||
isSpecialEnabled: false,
|
||||
removeSnowMock: vi.fn(),
|
||||
replyModalState: {
|
||||
activeCid: null,
|
||||
closeModal: vi.fn(),
|
||||
parentNumber: null,
|
||||
scrollY: 0,
|
||||
showReplyModal: false,
|
||||
subplebbitAddress: null,
|
||||
threadCid: null,
|
||||
threadNumber: null,
|
||||
} as ReplyModalShape,
|
||||
resolvedSubplebbitAddress: undefined as string | undefined,
|
||||
subplebbits: {} as Record<string, unknown>,
|
||||
useThemeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => testState.account,
|
||||
useAccountComment: ({ commentIndex }: { commentIndex?: number }) => (typeof commentIndex === 'number' ? testState.accountComments[commentIndex] : undefined),
|
||||
useSubplebbit: ({ subplebbitAddress }: { subplebbitAddress?: string }) => (subplebbitAddress ? testState.subplebbits[subplebbitAddress] : undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/use-account-subplebbit-addresses', () => ({
|
||||
useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/use-directories', () => ({
|
||||
useDirectories: () => testState.directories,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/use-is-mobile', () => ({
|
||||
default: () => testState.isMobile,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/use-resolved-subplebbit-address', () => ({
|
||||
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/use-theme', () => ({
|
||||
default: () => testState.useThemeMock(),
|
||||
}));
|
||||
|
||||
vi.mock('../stores/use-create-board-modal-store', () => ({
|
||||
default: () => ({
|
||||
closeCreateBoardModal: testState.closeCreateBoardModalMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../stores/use-reply-modal-store', () => ({
|
||||
default: () => testState.replyModalState,
|
||||
}));
|
||||
|
||||
vi.mock('../stores/use-special-theme-store', () => ({
|
||||
default: () => ({
|
||||
isEnabled: testState.isSpecialEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/snow', () => ({
|
||||
initSnow: (options: unknown) => testState.initSnowMock(options),
|
||||
removeSnow: () => testState.removeSnowMock(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/utils/preload-utils', () => ({
|
||||
preloadThemeAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeNamedComponent(name: string) {
|
||||
return () => createElement('div', { 'data-testid': name }, name);
|
||||
}
|
||||
|
||||
vi.mock('../components/board-buttons', () => ({
|
||||
DesktopBoardButtons: makeNamedComponent('desktop-board-buttons'),
|
||||
MobileBoardButtons: makeNamedComponent('mobile-board-buttons'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/board-header', () => ({
|
||||
default: makeNamedComponent('board-header'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/feed-cache-container', () => ({
|
||||
default: makeNamedComponent('feed-cache-container'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/post-form', () => ({
|
||||
default: makeNamedComponent('post-form'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/board-blotter', () => ({
|
||||
default: makeNamedComponent('board-blotter'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/boards-bar', () => ({
|
||||
default: makeNamedComponent('boards-bar'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/board', () => ({
|
||||
default: makeNamedComponent('board-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/blotter', () => ({
|
||||
default: makeNamedComponent('blotter-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/catalog', () => ({
|
||||
default: makeNamedComponent('catalog-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/faq', () => ({
|
||||
default: makeNamedComponent('faq-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/home', () => ({
|
||||
default: makeNamedComponent('home-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/mod-queue', () => ({
|
||||
default: makeNamedComponent('mod-queue-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/not-allowed', () => ({
|
||||
default: makeNamedComponent('not-allowed-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/not-found', () => ({
|
||||
default: makeNamedComponent('not-found-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/pending-post', () => ({
|
||||
default: makeNamedComponent('pending-post-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/post', () => ({
|
||||
default: makeNamedComponent('post-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/rules', () => ({
|
||||
default: makeNamedComponent('rules-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/account-data-editor', () => ({
|
||||
default: makeNamedComponent('account-data-editor-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/boards-bar-edit-modal', () => ({
|
||||
default: makeNamedComponent('boards-bar-edit-modal'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/create-board-modal', () => ({
|
||||
default: makeNamedComponent('create-board-modal'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/challenge-modal', () => ({
|
||||
default: makeNamedComponent('challenge-modal'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/directory-modal', () => ({
|
||||
default: makeNamedComponent('directory-modal'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/disclaimer-modal', () => ({
|
||||
default: makeNamedComponent('disclaimer-modal'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/settings-modal', () => ({
|
||||
default: makeNamedComponent('settings-modal'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/reply-modal', () => ({
|
||||
default: ({ parentCid, postCid }: { parentCid: string; postCid: string }) => createElement('div', { 'data-testid': 'reply-modal' }, `${parentCid}:${postCid}`),
|
||||
}));
|
||||
|
||||
let latestLocation = '';
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
React.useLayoutEffect(() => {
|
||||
latestLocation = `${location.pathname}${location.search}`;
|
||||
}, [location.pathname, location.search]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const flushEffects = async (count = 8) => {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderApp = async (initialEntry: string) => {
|
||||
latestLocation = initialEntry;
|
||||
await act(async () => {
|
||||
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(App), createElement(LocationProbe)));
|
||||
});
|
||||
await flushEffects();
|
||||
};
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
latestLocation = '';
|
||||
testState.account = { author: { address: '0x123' } };
|
||||
testState.accountComments = {};
|
||||
testState.accountSubplebbitAddresses = [];
|
||||
testState.isMobile = false;
|
||||
testState.isSpecialEnabled = false;
|
||||
testState.replyModalState = {
|
||||
activeCid: null,
|
||||
closeModal: vi.fn(),
|
||||
parentNumber: null,
|
||||
scrollY: 0,
|
||||
showReplyModal: false,
|
||||
subplebbitAddress: null,
|
||||
threadCid: null,
|
||||
threadNumber: null,
|
||||
} as ReplyModalShape;
|
||||
testState.resolvedSubplebbitAddress = undefined;
|
||||
testState.subplebbits = {};
|
||||
testState.useThemeMock.mockReset();
|
||||
testState.closeCreateBoardModalMock.mockReset();
|
||||
testState.initSnowMock.mockReset();
|
||||
testState.removeSnowMock.mockReset();
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders board layout chrome, settings modal, and reply modal wiring on settings routes', async () => {
|
||||
testState.replyModalState = {
|
||||
activeCid: 'parent-cid',
|
||||
closeModal: vi.fn(),
|
||||
parentNumber: 12,
|
||||
scrollY: 32,
|
||||
showReplyModal: true,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
threadCid: 'thread-cid',
|
||||
threadNumber: 99,
|
||||
} as ReplyModalShape;
|
||||
|
||||
await renderApp('/all/settings');
|
||||
|
||||
expect(container.querySelector('[data-testid="boards-bar"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="board-header"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="post-form"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="feed-cache-container"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="desktop-board-buttons"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="board-blotter"]')).toBeNull();
|
||||
expect(latestLocation).toBe('/all/settings');
|
||||
});
|
||||
|
||||
it('redirects board page 1 feeds to not-found', async () => {
|
||||
await renderApp('/mu/1');
|
||||
|
||||
expect(latestLocation).toBe('/not-found');
|
||||
expect(container.querySelector('[data-testid="not-found-view"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('canonicalizes board address routes to directory codes while preserving query strings', async () => {
|
||||
await renderApp('/music-posting.eth/thread/comment-1?focus=1');
|
||||
|
||||
expect(latestLocation).toBe('/mu/thread/comment-1?focus=1');
|
||||
expect(container.querySelector('[data-testid="post-view"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('routes invalid mod aliases and unknown mod paths to not-found', async () => {
|
||||
await renderApp('/mu/modqueue');
|
||||
expect(latestLocation).toBe('/not-found');
|
||||
expect(container.querySelector('[data-testid="not-found-view"]')).toBeTruthy();
|
||||
|
||||
await renderApp('/mod/asdf');
|
||||
expect(latestLocation).toBe('/mod/asdf');
|
||||
expect(container.querySelector('[data-testid="not-found-view"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows the global mod queue only when the account moderates at least one board', async () => {
|
||||
testState.accountSubplebbitAddresses = ['music-posting.eth'];
|
||||
await renderApp('/mod/queue');
|
||||
|
||||
expect(container.querySelector('[data-testid="mod-queue-view"]')).toBeTruthy();
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
testState.accountSubplebbitAddresses = [];
|
||||
await renderApp('/mod/queue');
|
||||
|
||||
expect(latestLocation).toBe('/not-allowed');
|
||||
expect(container.querySelector('[data-testid="not-allowed-view"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('enforces board-scoped mod queue access by account role', async () => {
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
testState.subplebbits = {
|
||||
'music-posting.eth': {
|
||||
state: 'succeeded',
|
||||
roles: {
|
||||
'0x123': { role: 'moderator' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderApp('/mu/mod/queue');
|
||||
expect(container.querySelector('[data-testid="mod-queue-view"]')).toBeTruthy();
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
testState.subplebbits = {
|
||||
'music-posting.eth': {
|
||||
state: 'succeeded',
|
||||
roles: {
|
||||
'0x123': { role: 'user' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderApp('/mu/mod/queue');
|
||||
expect(latestLocation).toBe('/not-allowed');
|
||||
expect(container.querySelector('[data-testid="not-allowed-view"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('starts and cleans up snow on desktop special-theme board layouts and closes create-board modal on mount', async () => {
|
||||
testState.isSpecialEnabled = true;
|
||||
|
||||
await renderApp('/mu');
|
||||
|
||||
expect(testState.initSnowMock).toHaveBeenCalledWith({ flakeCount: 150 });
|
||||
expect(testState.closeCreateBoardModalMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => root.unmount());
|
||||
expect(testState.removeSnowMock).toHaveBeenCalled();
|
||||
|
||||
root = createRoot(container);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
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 BoardsBar from '../boards-bar';
|
||||
|
||||
(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(() => ({
|
||||
accountComment: undefined as { subplebbitAddress?: string } | undefined,
|
||||
accountSubplebbitAddresses: ['music-posting.eth'] as string[],
|
||||
directories: [
|
||||
{ address: 'music-posting.eth', title: '/mu/ - Music' },
|
||||
{ address: 'tech-posting.eth', title: '/g/ - Technology' },
|
||||
] as Array<{ address: string; title?: string }>,
|
||||
directoriesMetadata: { title: '/all/ - All Boards' } as { title?: string } | null,
|
||||
initializeVisibilityMock: vi.fn(),
|
||||
navigateMock: vi.fn(),
|
||||
openBoardsBarEditModalMock: vi.fn(),
|
||||
openCreateBoardModalMock: vi.fn(),
|
||||
openDirectoryModalMock: vi.fn(),
|
||||
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
|
||||
showSubscriptionsInBoardsBar: true,
|
||||
subscriptions: ['custom.eth'] as string[],
|
||||
visibleDirectories: new Set<string>(['mu']),
|
||||
}));
|
||||
|
||||
function useBoardsBarVisibilityStoreMock() {
|
||||
return {
|
||||
visibleDirectories: testState.visibleDirectories,
|
||||
showSubscriptionsInBoardsBar: testState.showSubscriptionsInBoardsBar,
|
||||
};
|
||||
}
|
||||
|
||||
useBoardsBarVisibilityStoreMock.getState = () => ({
|
||||
initialize: testState.initializeVisibilityMock,
|
||||
});
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/accounts', () => ({
|
||||
default: (selector: (state: { activeAccountId: string; accounts: Record<string, { subscriptions: string[] }> }) => unknown) =>
|
||||
selector({
|
||||
activeAccountId: 'account-1',
|
||||
accounts: {
|
||||
'account-1': {
|
||||
subscriptions: testState.subscriptions,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({
|
||||
useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../hooks/use-directories')>('../../../hooks/use-directories');
|
||||
return {
|
||||
...actual,
|
||||
useDirectories: () => testState.directories,
|
||||
useDirectoriesMetadata: () => testState.directoriesMetadata,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
||||
useBoardPath: (subplebbitAddress: string | undefined) => {
|
||||
if (subplebbitAddress === 'music-posting.eth') return 'mu';
|
||||
if (subplebbitAddress === 'tech-posting.eth') return 'g';
|
||||
return subplebbitAddress;
|
||||
},
|
||||
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-create-board-modal-store', () => ({
|
||||
default: () => ({
|
||||
openCreateBoardModal: testState.openCreateBoardModalMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-boards-bar-edit-modal-store', () => ({
|
||||
default: () => ({
|
||||
openBoardsBarEditModal: testState.openBoardsBarEditModalMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-boards-bar-visibility-store', () => ({
|
||||
default: useBoardsBarVisibilityStoreMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-directory-modal-store', () => ({
|
||||
default: () => ({
|
||||
openDirectoryModal: testState.openDirectoryModalMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('lodash/debounce', () => ({
|
||||
default: <T extends (...args: any[]) => void>(fn: T) => {
|
||||
const wrapped = ((...args: Parameters<T>) => fn(...args)) as T & { cancel: () => void };
|
||||
wrapped.cancel = () => undefined;
|
||||
return wrapped;
|
||||
},
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const findExactText = (text: string) => Array.from(container.querySelectorAll<HTMLElement>('*')).find((element) => element.textContent?.trim() === text);
|
||||
|
||||
const renderBoardsBar = async (initialEntry: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: [initialEntry] },
|
||||
createElement(
|
||||
Routes,
|
||||
{},
|
||||
createElement(Route, { path: '/:boardIdentifier/*', element: createElement(BoardsBar) }),
|
||||
createElement(Route, { path: '/pending/:accountCommentIndex/*', element: createElement(BoardsBar) }),
|
||||
createElement(Route, { path: '*', element: createElement(BoardsBar) }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
describe('BoardsBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.accountComment = undefined;
|
||||
testState.accountSubplebbitAddresses = ['music-posting.eth'];
|
||||
testState.directories = [
|
||||
{ address: 'music-posting.eth', title: '/mu/ - Music' },
|
||||
{ address: 'tech-posting.eth', title: '/g/ - Technology' },
|
||||
];
|
||||
testState.directoriesMetadata = { title: '/all/ - All Boards' };
|
||||
testState.navigateMock.mockReset();
|
||||
testState.openBoardsBarEditModalMock.mockReset();
|
||||
testState.openCreateBoardModalMock.mockReset();
|
||||
testState.openDirectoryModalMock.mockReset();
|
||||
testState.initializeVisibilityMock.mockReset();
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
testState.showSubscriptionsInBoardsBar = true;
|
||||
testState.subscriptions = ['custom.eth'];
|
||||
testState.visibleDirectories = new Set(['mu']);
|
||||
|
||||
Object.defineProperty(window, 'scrollY', {
|
||||
configurable: true,
|
||||
value: 0,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders desktop board groups and subscription links, then opens edit/create/directory actions', async () => {
|
||||
await renderBoardsBar('/mu');
|
||||
|
||||
expect(testState.initializeVisibilityMock).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain('all');
|
||||
expect(container.textContent).toContain('subs');
|
||||
expect(container.textContent).toContain('mod');
|
||||
expect(container.textContent).toContain('custom.eth');
|
||||
expect(container.textContent).toContain('mu');
|
||||
expect(container.textContent).not.toContain('g /');
|
||||
|
||||
await act(async () => {
|
||||
findExactText('...')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('g');
|
||||
|
||||
await act(async () => {
|
||||
findExactText('Edit')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
findExactText('create_board')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(testState.openBoardsBarEditModalMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.openCreateBoardModalMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
findExactText('biz')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(testState.openDirectoryModalMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the desktop search bar and submits entered board addresses', async () => {
|
||||
await renderBoardsBar('/mu');
|
||||
|
||||
await act(async () => {
|
||||
findExactText('search')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
const inputs = container.querySelectorAll<HTMLInputElement>('input[type="text"]');
|
||||
const desktopSearchInput = inputs[0];
|
||||
expect(desktopSearchInput).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
desktopSearchInput.value = 'new-board.eth';
|
||||
desktopSearchInput.form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/new-board.eth');
|
||||
});
|
||||
|
||||
it('keeps catalog navigation on mobile board changes and hides the navbar on downward scroll', async () => {
|
||||
await renderBoardsBar('/mu/catalog');
|
||||
|
||||
const select = container.querySelector('select');
|
||||
expect(select).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
if (select) {
|
||||
select.value = 'all';
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/all/catalog');
|
||||
|
||||
const mobileNav = container.querySelector<HTMLElement>('[style*="translateY"]');
|
||||
expect(mobileNav?.style.transform).toBe('translateY(0)');
|
||||
|
||||
await act(async () => {
|
||||
window.scrollY = 100;
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
|
||||
expect(mobileNav?.style.transform).toBe('translateY(-23px)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
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 PostForm, { LinkTypePreviewer } from '../post-form';
|
||||
|
||||
(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(() => ({
|
||||
account: {
|
||||
author: { displayName: 'Alice' },
|
||||
subscriptions: ['music-posting.eth'],
|
||||
},
|
||||
accountComment: undefined as { subplebbitAddress?: string } | undefined,
|
||||
accountSubplebbitAddresses: ['mod.eth'] as string[],
|
||||
comments: {} as Record<string, { deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean }>,
|
||||
directories: [
|
||||
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
||||
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
|
||||
] as Array<{ address: string; features?: Record<string, unknown>; title?: string }>,
|
||||
editedComment: undefined as { deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined,
|
||||
gifFrameStatus: 'idle' as 'idle' | 'ready',
|
||||
handleUploadMock: vi.fn(),
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: false,
|
||||
navigateMock: vi.fn(),
|
||||
offlineTitle: 'offline board',
|
||||
postIndex: undefined as number | undefined,
|
||||
publishPostMock: vi.fn(),
|
||||
publishReplyMock: vi.fn(),
|
||||
replyIndex: undefined as number | undefined,
|
||||
resetPublishPostOptionsMock: vi.fn(),
|
||||
resetPublishReplyOptionsMock: vi.fn(),
|
||||
resolvedSubplebbitAddress: undefined as string | undefined,
|
||||
setAccountMock: vi.fn(),
|
||||
setPublishPostOptionsMock: vi.fn(),
|
||||
setPublishReplyOptionsMock: vi.fn(),
|
||||
showUploadControls: true,
|
||||
subplebbits: {
|
||||
'music-posting.eth': { address: 'music-posting.eth' },
|
||||
} as Record<string, unknown>,
|
||||
uploadComplete: undefined as ((uploadedUrl: string) => void) | undefined,
|
||||
uploadMode: 'always',
|
||||
uploadedFileName: 'picked.png' as string | null,
|
||||
}));
|
||||
|
||||
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', () => ({
|
||||
setAccount: (account: unknown) => testState.setAccountMock(account),
|
||||
useAccount: () => testState.account,
|
||||
useAccountComment: () => testState.accountComment,
|
||||
useEditedComment: () => ({ editedComment: testState.editedComment }),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits', () => ({
|
||||
default: (selector: (state: { subplebbits: typeof testState.subplebbits }) => unknown) => selector({ subplebbits: testState.subplebbits }),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({
|
||||
default: (selector: (state: { comments: typeof testState.comments }) => unknown) => selector({ comments: testState.comments }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({
|
||||
useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses,
|
||||
}));
|
||||
|
||||
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('../../../hooks/use-fetch-gif-first-frame', () => ({
|
||||
default: () => ({
|
||||
status: testState.gifFrameStatus,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({
|
||||
default: () => ({
|
||||
isOffline: testState.isOffline,
|
||||
isOnlineStatusLoading: testState.isOnlineStatusLoading,
|
||||
offlineTitle: testState.offlineTitle,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||
default: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-publish-post', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react');
|
||||
|
||||
return {
|
||||
default: ({ subplebbitAddress }: { subplebbitAddress?: string }) => {
|
||||
const [publishPostOptions, setPublishPostOptionsState] = React.useState<Record<string, unknown>>(subplebbitAddress ? { subplebbitAddress } : {});
|
||||
|
||||
return {
|
||||
postIndex: testState.postIndex,
|
||||
publishPost: testState.publishPostMock,
|
||||
publishPostOptions,
|
||||
resetPublishPostOptions: testState.resetPublishPostOptionsMock,
|
||||
setPublishPostOptions: (options: Record<string, unknown>) => {
|
||||
testState.setPublishPostOptionsMock(options);
|
||||
setPublishPostOptionsState((previous) => ({ ...previous, ...options }));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-publish-reply', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react');
|
||||
|
||||
return {
|
||||
default: ({ cid, postCid, subplebbitAddress }: { cid: string; postCid?: string; subplebbitAddress: string }) => {
|
||||
const [publishReplyOptions, setPublishReplyOptionsState] = React.useState<Record<string, unknown>>({
|
||||
parentCid: cid,
|
||||
postCid: postCid ?? cid,
|
||||
subplebbitAddress,
|
||||
});
|
||||
|
||||
return {
|
||||
publishReply: testState.publishReplyMock,
|
||||
replyIndex: testState.replyIndex,
|
||||
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
||||
setPublishReplyOptions: (options: Record<string, unknown>) => {
|
||||
testState.setPublishReplyOptionsMock(options);
|
||||
setPublishReplyOptionsState((previous) => ({ ...previous, ...options }));
|
||||
},
|
||||
_publishReplyOptions: publishReplyOptions,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-file-upload', () => ({
|
||||
useFileUpload: ({ onUploadComplete }: { onUploadComplete: (uploadedUrl: string) => void }) => {
|
||||
testState.uploadComplete = onUploadComplete;
|
||||
return {
|
||||
handleUpload: testState.handleUploadMock,
|
||||
isUploading: false,
|
||||
uploadedFileName: testState.uploadedFileName,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/utils/media-utils', () => ({
|
||||
getLinkMediaInfo: (link: string) => {
|
||||
if (link.endsWith('.gif')) {
|
||||
return { type: 'gif', url: link };
|
||||
}
|
||||
if (link.endsWith('.png')) {
|
||||
return { type: 'image', url: link };
|
||||
}
|
||||
return { type: 'link', url: link };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
||||
getShowUploadControls: () => testState.showUploadControls,
|
||||
isWebRuntime: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-media-hosting-store', () => ({
|
||||
default: (selector: (state: { uploadMode: string }) => unknown) =>
|
||||
selector({
|
||||
uploadMode: testState.uploadMode,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('lodash/debounce', () => ({
|
||||
default: <T extends (...args: any[]) => void>(fn: T) => {
|
||||
const wrapped = ((...args: Parameters<T>) => fn(...args)) as T & { cancel: () => void };
|
||||
wrapped.cancel = () => undefined;
|
||||
return wrapped;
|
||||
},
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const flushEffects = async (count = 4) => {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderPostForm = async (initialEntry: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: [initialEntry] },
|
||||
createElement(
|
||||
Routes,
|
||||
{},
|
||||
createElement(Route, { path: '/all/*', element: createElement(PostForm) }),
|
||||
createElement(Route, { path: '/subs/*', element: createElement(PostForm) }),
|
||||
createElement(Route, { path: '/mod/*', element: createElement(PostForm) }),
|
||||
createElement(Route, { path: '/:boardIdentifier/thread/:commentCid/*', element: createElement(PostForm) }),
|
||||
createElement(Route, { path: '/:boardIdentifier/*', element: createElement(PostForm) }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
await flushEffects();
|
||||
};
|
||||
|
||||
const clickByText = async (scope: ParentNode, text: string, index = 0) => {
|
||||
const button = Array.from(scope.querySelectorAll('button')).filter((candidate) => candidate.textContent === text)[index] as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||
await act(async () => {
|
||||
element.value = value;
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchChange = async (element: HTMLInputElement | HTMLSelectElement, value: string | boolean) => {
|
||||
await act(async () => {
|
||||
if (typeof value === 'boolean' && 'checked' in element) {
|
||||
element.checked = value;
|
||||
} else {
|
||||
element.value = String(value);
|
||||
}
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
describe('PostForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.account = {
|
||||
author: { displayName: 'Alice' },
|
||||
subscriptions: ['music-posting.eth'],
|
||||
};
|
||||
testState.accountComment = undefined;
|
||||
testState.accountSubplebbitAddresses = ['mod.eth'];
|
||||
testState.comments = {};
|
||||
testState.directories = [
|
||||
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
||||
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
|
||||
];
|
||||
testState.editedComment = undefined;
|
||||
testState.gifFrameStatus = 'idle';
|
||||
testState.isOffline = false;
|
||||
testState.isOnlineStatusLoading = false;
|
||||
testState.offlineTitle = 'offline board';
|
||||
testState.postIndex = undefined;
|
||||
testState.replyIndex = undefined;
|
||||
testState.resolvedSubplebbitAddress = undefined;
|
||||
testState.showUploadControls = true;
|
||||
testState.uploadComplete = undefined;
|
||||
testState.uploadMode = 'always';
|
||||
testState.uploadedFileName = 'picked.png';
|
||||
testState.subplebbits = {
|
||||
'music-posting.eth': { address: 'music-posting.eth' },
|
||||
};
|
||||
testState.handleUploadMock.mockReset();
|
||||
testState.navigateMock.mockReset();
|
||||
testState.publishPostMock.mockReset();
|
||||
testState.publishReplyMock.mockReset();
|
||||
testState.resetPublishPostOptionsMock.mockReset();
|
||||
testState.resetPublishReplyOptionsMock.mockReset();
|
||||
testState.setAccountMock.mockReset();
|
||||
testState.setPublishPostOptionsMock.mockReset();
|
||||
testState.setPublishReplyOptionsMock.mockReset();
|
||||
Object.defineProperty(globalThis, 'alert', {
|
||||
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('shows the closed-thread notice when the current post can no longer receive replies', async () => {
|
||||
testState.comments = {
|
||||
'thread-cid': {
|
||||
locked: true,
|
||||
postCid: 'thread-cid',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPostForm('/mu/thread/thread-cid');
|
||||
|
||||
expect(container.textContent).toContain('thread_closed');
|
||||
expect(container.textContent).toContain('may_not_reply');
|
||||
});
|
||||
|
||||
it('opens the new-thread form, validates all-view requirements, and publishes a board post', async () => {
|
||||
await renderPostForm('/all');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
|
||||
const table = container.querySelector('table');
|
||||
expect(table).toBeTruthy();
|
||||
|
||||
await clickByText(table as HTMLTableElement, 'choose_file');
|
||||
expect(testState.handleUploadMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('empty_comment_alert');
|
||||
|
||||
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
||||
const nameInput = textInputs[0];
|
||||
const subjectInput = textInputs[1];
|
||||
const linkInput = textInputs[2];
|
||||
const textarea = table?.querySelector('textarea');
|
||||
const select = table?.querySelector('select');
|
||||
|
||||
expect(nameInput).toBeTruthy();
|
||||
expect(subjectInput).toBeTruthy();
|
||||
expect(linkInput).toBeTruthy();
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(select).toBeTruthy();
|
||||
|
||||
(globalThis.alert as ReturnType<typeof vi.fn>).mockClear();
|
||||
await dispatchInput(linkInput as HTMLInputElement, 'not-a-url');
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('invalid_url_alert');
|
||||
|
||||
(globalThis.alert as ReturnType<typeof vi.fn>).mockClear();
|
||||
await dispatchInput(linkInput as HTMLInputElement, '');
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'A valid body');
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('no_board_selected_warning');
|
||||
|
||||
await dispatchChange(select as HTMLSelectElement, 'music-posting.eth');
|
||||
await dispatchInput(nameInput as HTMLInputElement, 'Alice Cooper');
|
||||
await dispatchInput(subjectInput as HTMLInputElement, 'A thread');
|
||||
|
||||
const spoilerToggle = table?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
if (spoilerToggle) {
|
||||
await dispatchChange(spoilerToggle, true);
|
||||
}
|
||||
|
||||
(globalThis.alert as ReturnType<typeof vi.fn>).mockClear();
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
|
||||
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ subplebbitAddress: 'music-posting.eth' });
|
||||
});
|
||||
|
||||
it('redirects to the pending route when a post publish index is already available on mount', async () => {
|
||||
testState.postIndex = 7;
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
|
||||
await renderPostForm('/mu');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
await flushEffects();
|
||||
|
||||
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/pending/7');
|
||||
});
|
||||
|
||||
it('resets the reply form after a completed reply publish', async () => {
|
||||
testState.comments = {
|
||||
'thread-cid': {
|
||||
postCid: 'thread-cid',
|
||||
},
|
||||
};
|
||||
testState.replyIndex = 4;
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
|
||||
await renderPostForm('/mu/thread/thread-cid');
|
||||
await clickByText(container, 'post_a_reply');
|
||||
await flushEffects();
|
||||
|
||||
expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector('table')).toBeNull();
|
||||
});
|
||||
|
||||
it('publishes replies from the open reply form', async () => {
|
||||
testState.comments = {
|
||||
'thread-cid': {
|
||||
postCid: 'thread-cid',
|
||||
},
|
||||
};
|
||||
testState.isOffline = true;
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
|
||||
await renderPostForm('/mu/thread/thread-cid');
|
||||
await clickByText(container, 'post_a_reply');
|
||||
|
||||
const table = container.querySelector('table');
|
||||
const textarea = table?.querySelector('textarea');
|
||||
expect(table).toBeTruthy();
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(container.textContent).toContain('offline board');
|
||||
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'Reply body');
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
|
||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LinkTypePreviewer', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('describes gif previews and invalid links for the post form link helper', async () => {
|
||||
testState.gifFrameStatus = 'ready';
|
||||
await act(async () => {
|
||||
root.render(createElement(LinkTypePreviewer, { link: 'https://example.com/file.gif' }));
|
||||
});
|
||||
expect(container.textContent).toBe('animated_gif');
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(LinkTypePreviewer, { link: 'not-a-url' }));
|
||||
});
|
||||
expect(container.textContent).toBe('invalid_url');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { isValidURL } from '../../lib/utils/url-utils';
|
||||
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { useAccountSubplebbitAddresses } from '../../hooks/use-account-subplebbit-addresses';
|
||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
||||
@@ -513,6 +514,7 @@ const PostForm = () => {
|
||||
const isInModQueueView = isModQueueView(location.pathname);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
||||
const isInCatalogView = isCatalogView(location.pathname, params);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const commentCid = params?.commentCid;
|
||||
const post = useSubplebbitsPagesStore((state) => state.comments[commentCid as string]);
|
||||
@@ -532,32 +534,12 @@ const PostForm = () => {
|
||||
const resolvedAddress = useResolvedSubplebbitAddress();
|
||||
const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.postFormDesktop}>
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && showForm && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
|
||||
{isInModQueueView ? (
|
||||
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
|
||||
) : isThreadClosed ? (
|
||||
<div className={styles.closed}>
|
||||
{t('thread_closed')}
|
||||
<br />
|
||||
{t('may_not_reply')}
|
||||
</div>
|
||||
) : !showForm ? (
|
||||
<div>
|
||||
[
|
||||
<button className='button' onClick={() => setShowForm(true)}>
|
||||
{isInPostView ? t('post_a_reply') : t('start_new_thread')}
|
||||
</button>
|
||||
]
|
||||
</div>
|
||||
) : (
|
||||
<PostFormTable closeForm={() => setShowForm(false)} postCid={postCid} />
|
||||
)}
|
||||
</div>
|
||||
const shouldShowOfflineAlert = !(isInAllView || isInSubscriptionsView || isInModView) && showForm;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className={styles.postFormMobile}>
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && showForm && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
|
||||
{shouldShowOfflineAlert && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
|
||||
{isInModQueueView ? (
|
||||
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
|
||||
) : isThreadClosed ? (
|
||||
@@ -576,7 +558,32 @@ const PostForm = () => {
|
||||
)}
|
||||
{isInCatalogView && <hr />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.postFormDesktop}>
|
||||
{shouldShowOfflineAlert && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
|
||||
{isInModQueueView ? (
|
||||
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
|
||||
) : isThreadClosed ? (
|
||||
<div className={styles.closed}>
|
||||
{t('thread_closed')}
|
||||
<br />
|
||||
{t('may_not_reply')}
|
||||
</div>
|
||||
) : !showForm ? (
|
||||
<div>
|
||||
[
|
||||
<button className='button' onClick={() => setShowForm(true)}>
|
||||
{isInPostView ? t('post_a_reply') : t('start_new_thread')}
|
||||
</button>
|
||||
]
|
||||
</div>
|
||||
) : (
|
||||
<PostFormTable closeForm={() => setShowForm(false)} postCid={postCid} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import usePublishPost from '../use-publish-post';
|
||||
import useChallengesStore from '../../stores/use-challenges-store';
|
||||
import usePublishPostStore from '../../stores/use-publish-post-store';
|
||||
|
||||
(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(() => ({
|
||||
abandonPublishMock: vi.fn(async () => undefined),
|
||||
index: 12,
|
||||
lastPublishOptions: undefined as Record<string, any> | undefined,
|
||||
publishCommentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
usePublishComment: (options: Record<string, any>) => {
|
||||
testState.lastPublishOptions = options;
|
||||
return {
|
||||
abandonPublish: testState.abandonPublishMock,
|
||||
index: testState.index,
|
||||
publishComment: testState.publishCommentMock,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let latestValue: ReturnType<typeof usePublishPost>;
|
||||
let root: Root;
|
||||
|
||||
const HookHarness = () => {
|
||||
latestValue = usePublishPost({ subplebbitAddress: 'music.eth' });
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderHook = () => {
|
||||
act(() => {
|
||||
root.render(createElement(HookHarness));
|
||||
});
|
||||
};
|
||||
|
||||
describe('usePublishPost', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.index = 12;
|
||||
testState.lastPublishOptions = undefined;
|
||||
useChallengesStore.setState({ challenges: [] });
|
||||
usePublishPostStore.getState().resetPublishPostStore();
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
renderHook();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('sanitizes publish options into portable store state and exposes the publish action', async () => {
|
||||
await act(async () => {
|
||||
latestValue.setPublishPostOptions({
|
||||
author: { displayName: 'Alice' },
|
||||
content: '',
|
||||
link: '',
|
||||
spoiler: true,
|
||||
title: 'Hello world',
|
||||
} as never);
|
||||
});
|
||||
|
||||
expect(latestValue.postIndex).toBe(12);
|
||||
expect(latestValue.publishPost).toBe(testState.publishCommentMock);
|
||||
expect(latestValue.publishPostOptions).toMatchObject({
|
||||
author: { displayName: 'Alice' },
|
||||
content: undefined,
|
||||
link: undefined,
|
||||
spoiler: true,
|
||||
subplebbitAddress: 'music.eth',
|
||||
title: 'Hello world',
|
||||
});
|
||||
expect(typeof latestValue.publishPostOptions.onChallengeVerification).toBe('function');
|
||||
expect(typeof latestValue.publishPostOptions.onError).toBe('function');
|
||||
});
|
||||
|
||||
it('routes publish challenges through the challenge store and abandons the current publish when requested', async () => {
|
||||
await act(async () => {
|
||||
latestValue.setPublishPostOptions({
|
||||
content: 'Thread body',
|
||||
} as never);
|
||||
});
|
||||
|
||||
expect(typeof testState.lastPublishOptions?.onChallenge).toBe('function');
|
||||
|
||||
await act(async () => {
|
||||
await testState.lastPublishOptions?.onChallenge('captcha', 'nonce');
|
||||
});
|
||||
|
||||
const challenges = useChallengesStore.getState().challenges;
|
||||
expect(challenges).toHaveLength(1);
|
||||
expect(challenges[0]?.challenge).toEqual(['captcha', 'nonce']);
|
||||
|
||||
await act(async () => {
|
||||
await useChallengesStore.getState().abandonCurrentChallenge();
|
||||
});
|
||||
|
||||
expect(testState.abandonPublishMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
latestValue.resetPublishPostOptions();
|
||||
});
|
||||
|
||||
expect(latestValue.publishPostOptions).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import usePublishReply from '../use-publish-reply';
|
||||
import useChallengesStore from '../../stores/use-challenges-store';
|
||||
import usePostNumberStore from '../../stores/use-post-number-store';
|
||||
import usePublishReplyStore from '../../stores/use-publish-reply-store';
|
||||
|
||||
(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(() => ({
|
||||
abandonPublishMock: vi.fn(async () => undefined),
|
||||
index: 7,
|
||||
lastPublishOptions: undefined as Record<string, any> | undefined,
|
||||
publishCommentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
usePublishComment: (options: Record<string, any>) => {
|
||||
testState.lastPublishOptions = options;
|
||||
return {
|
||||
abandonPublish: testState.abandonPublishMock,
|
||||
index: testState.index,
|
||||
publishComment: testState.publishCommentMock,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let latestValue: ReturnType<typeof usePublishReply>;
|
||||
let root: Root;
|
||||
|
||||
const HookHarness = () => {
|
||||
latestValue = usePublishReply({ cid: 'parent-cid', subplebbitAddress: 'music.eth' });
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderHook = () => {
|
||||
act(() => {
|
||||
root.render(createElement(HookHarness));
|
||||
});
|
||||
};
|
||||
|
||||
describe('usePublishReply', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.index = 7;
|
||||
testState.lastPublishOptions = undefined;
|
||||
useChallengesStore.setState({ challenges: [] });
|
||||
usePostNumberStore.setState({ cidToNumber: {}, numberToCid: { 'music.eth': { 12: 'quoted-cid' } } });
|
||||
usePublishReplyStore.setState({
|
||||
author: {},
|
||||
content: {},
|
||||
displayName: {},
|
||||
link: {},
|
||||
publishCommentOptions: {},
|
||||
spoiler: {},
|
||||
});
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
renderHook();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('builds reply publish options with derived quoted cids and exposes the publish action', async () => {
|
||||
await act(async () => {
|
||||
latestValue.setPublishReplyOptions({
|
||||
author: { displayName: 'Bob' },
|
||||
content: 'Replying to >>12',
|
||||
link: '',
|
||||
spoiler: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
expect(latestValue.replyIndex).toBe(7);
|
||||
expect(latestValue.publishReply).toBe(testState.publishCommentMock);
|
||||
expect(testState.lastPublishOptions).toMatchObject({
|
||||
author: { displayName: 'Bob' },
|
||||
content: 'Replying to >>12',
|
||||
link: undefined,
|
||||
parentCid: 'parent-cid',
|
||||
postCid: 'parent-cid',
|
||||
quotedCids: ['quoted-cid'],
|
||||
spoiler: true,
|
||||
subplebbitAddress: 'music.eth',
|
||||
});
|
||||
});
|
||||
|
||||
it('queues reply challenges and clears the scoped reply store on reset', async () => {
|
||||
await act(async () => {
|
||||
latestValue.setPublishReplyOptions({
|
||||
content: 'Body',
|
||||
} as never);
|
||||
});
|
||||
|
||||
expect(typeof testState.lastPublishOptions?.onChallenge).toBe('function');
|
||||
|
||||
await act(async () => {
|
||||
await testState.lastPublishOptions?.onChallenge('captcha');
|
||||
});
|
||||
|
||||
expect(useChallengesStore.getState().challenges).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
await useChallengesStore.getState().abandonCurrentChallenge();
|
||||
});
|
||||
|
||||
expect(testState.abandonPublishMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
latestValue.resetPublishReplyOptions();
|
||||
});
|
||||
|
||||
expect(usePublishReplyStore.getState().publishCommentOptions['parent-cid']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
commentMatchesPatternMock: vi.fn((comment: { content?: string; title?: string; link?: string }, pattern: string) => {
|
||||
const haystack = [comment?.title, comment?.content, comment?.link].filter(Boolean).join(' ').toLowerCase();
|
||||
return haystack.includes(pattern.toLowerCase());
|
||||
}),
|
||||
}));
|
||||
|
||||
type CatalogFiltersStoreModule = typeof import('../use-catalog-filters-store');
|
||||
type CatalogFiltersStore = Awaited<ReturnType<typeof loadStore>>;
|
||||
|
||||
const STORAGE_KEY = 'catalog-filters-storage';
|
||||
|
||||
const loadStore = async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock('../../lib/utils/pattern-utils', () => ({
|
||||
commentMatchesPattern: (comment: unknown, pattern: string) => testState.commentMatchesPatternMock(comment as never, pattern),
|
||||
}));
|
||||
|
||||
const module = (await import('../use-catalog-filters-store')) as CatalogFiltersStoreModule;
|
||||
await Promise.resolve();
|
||||
return module.default;
|
||||
};
|
||||
|
||||
const createFilterItem = (text: string, overrides: Record<string, unknown> = {}) => ({
|
||||
text,
|
||||
enabled: true,
|
||||
count: 0,
|
||||
filteredCids: new Set<string>(),
|
||||
subplebbitCounts: new Map<string, number>(),
|
||||
subplebbitFilteredCids: new Map<string, Set<string>>(),
|
||||
hide: true,
|
||||
top: false,
|
||||
color: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useCatalogFiltersStore', () => {
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.doUnmock('../../lib/utils/pattern-utils');
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('sanitizes filter items and persists only portable filter fields', async () => {
|
||||
const useCatalogFiltersStore = await loadStore();
|
||||
|
||||
useCatalogFiltersStore
|
||||
.getState()
|
||||
.setFilterItems([
|
||||
createFilterItem('spam', { color: 'red' }),
|
||||
createFilterItem(' '),
|
||||
createFilterItem('news', { enabled: false, hide: false, top: true }),
|
||||
] as never);
|
||||
|
||||
const { filterItems } = useCatalogFiltersStore.getState();
|
||||
expect(filterItems).toHaveLength(2);
|
||||
expect(filterItems[0]).toMatchObject({
|
||||
text: 'spam',
|
||||
enabled: true,
|
||||
count: 0,
|
||||
hide: true,
|
||||
top: false,
|
||||
color: 'red',
|
||||
});
|
||||
expect(filterItems[0].filteredCids).toEqual(new Set());
|
||||
expect(filterItems[0].subplebbitCounts).toEqual(new Map());
|
||||
expect(filterItems[1]).toMatchObject({
|
||||
text: 'news',
|
||||
enabled: false,
|
||||
hide: false,
|
||||
top: true,
|
||||
});
|
||||
|
||||
const persisted = localStorage.getItem(STORAGE_KEY) ?? '';
|
||||
expect(persisted).toContain('"text":"spam"');
|
||||
expect(persisted).toContain('"hide":true');
|
||||
expect(persisted).not.toContain('"count"');
|
||||
expect(persisted).not.toContain('"filteredCids"');
|
||||
});
|
||||
|
||||
it('applies search and content filters, hides matching comments, and counts hidden cids only once per board', async () => {
|
||||
const useCatalogFiltersStore = await loadStore();
|
||||
|
||||
useCatalogFiltersStore.getState().setFilterItems([createFilterItem('spam', { hide: true }), createFilterItem('highlight', { hide: false, top: true })] as never);
|
||||
useCatalogFiltersStore.getState().setCurrentSubplebbitAddress('music.eth');
|
||||
useCatalogFiltersStore.getState().setSearchFilter('topic');
|
||||
|
||||
const filter = useCatalogFiltersStore.getState().filter;
|
||||
expect(filter).toBeTypeOf('function');
|
||||
|
||||
expect(filter?.({ cid: 'cid-1', content: 'topic spam', subplebbitAddress: 'music.eth' } as never)).toBe(false);
|
||||
expect(filter?.({ cid: 'cid-1', content: 'topic spam', subplebbitAddress: 'music.eth' } as never)).toBe(false);
|
||||
expect(filter?.({ cid: 'cid-2', content: 'topic spam', subplebbitAddress: 'tech.eth' } as never)).toBe(false);
|
||||
expect(filter?.({ cid: 'cid-3', content: 'topic highlight', subplebbitAddress: 'music.eth' } as never)).toBe(true);
|
||||
expect(filter?.({ cid: 'cid-4', content: 'ordinary update', subplebbitAddress: 'music.eth' } as never)).toBe(false);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
const state = useCatalogFiltersStore.getState();
|
||||
expect(testState.commentMatchesPatternMock).toHaveBeenCalled();
|
||||
expect(state.filterItems[0].count).toBe(1);
|
||||
expect(state.filterItems[0].filteredCids).toEqual(new Set(['cid-1']));
|
||||
expect(state.filterItems[0].subplebbitCounts.get('music.eth')).toBe(1);
|
||||
expect(state.filteredCount).toBe(1);
|
||||
expect(state.getFilteredCountForCurrentSubplebbit()).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves counts for unchanged filters when saving and clears matched filters', async () => {
|
||||
const useCatalogFiltersStore = await loadStore();
|
||||
|
||||
useCatalogFiltersStore.getState().setFilterItems([createFilterItem('spam')] as never);
|
||||
useCatalogFiltersStore.getState().setCurrentSubplebbitAddress('music.eth');
|
||||
useCatalogFiltersStore.getState().incrementFilterCount(0, 'cid-1', 'music.eth');
|
||||
useCatalogFiltersStore.getState().setMatchedFilter('cid-1', 'red');
|
||||
|
||||
useCatalogFiltersStore
|
||||
.getState()
|
||||
.saveAndApplyFilters([createFilterItem('spam', { color: 'orange' }), createFilterItem('eggs', { enabled: false, top: true })] as never);
|
||||
|
||||
const { filterItems, filteredCids, matchedFilters } = useCatalogFiltersStore.getState();
|
||||
expect(filterItems).toHaveLength(2);
|
||||
expect(filterItems[0].text).toBe('spam');
|
||||
expect(filterItems[0].count).toBe(1);
|
||||
expect(filterItems[0].subplebbitCounts.get('music.eth')).toBe(1);
|
||||
expect(filterItems[0].color).toBe('orange');
|
||||
expect(filterItems[1]).toMatchObject({
|
||||
text: 'eggs',
|
||||
count: 0,
|
||||
enabled: false,
|
||||
top: true,
|
||||
});
|
||||
expect(filterItems[1].subplebbitCounts).toEqual(new Map());
|
||||
expect(filteredCids).toEqual(new Set());
|
||||
expect(matchedFilters.size).toBe(0);
|
||||
});
|
||||
|
||||
it('switches current boards, recalculates counts, and resets only the active board counters', async () => {
|
||||
const useCatalogFiltersStore = await loadStore();
|
||||
|
||||
useCatalogFiltersStore.getState().setFilterItems([createFilterItem('spam')] as never);
|
||||
useCatalogFiltersStore.getState().setCurrentSubplebbitAddress('music.eth');
|
||||
useCatalogFiltersStore.getState().incrementFilterCount(0, 'cid-1', 'music.eth');
|
||||
useCatalogFiltersStore.getState().incrementFilterCount(0, 'cid-2', 'tech.eth');
|
||||
|
||||
expect(useCatalogFiltersStore.getState().getFilteredCountForCurrentSubplebbit()).toBe(1);
|
||||
|
||||
useCatalogFiltersStore.getState().setCurrentSubplebbitAddress('tech.eth');
|
||||
expect(useCatalogFiltersStore.getState().currentSubplebbitAddress).toBe('tech.eth');
|
||||
expect(useCatalogFiltersStore.getState().getFilteredCountForCurrentSubplebbit()).toBe(1);
|
||||
|
||||
useCatalogFiltersStore.getState().resetCountsForCurrentSubplebbit();
|
||||
expect(useCatalogFiltersStore.getState().getFilteredCountForCurrentSubplebbit()).toBe(0);
|
||||
|
||||
useCatalogFiltersStore.getState().setCurrentSubplebbitAddress('music.eth');
|
||||
expect(useCatalogFiltersStore.getState().getFilteredCountForCurrentSubplebbit()).toBe(1);
|
||||
|
||||
useCatalogFiltersStore.getState().clearSearchFilter();
|
||||
expect(useCatalogFiltersStore.getState().searchText).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user