mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
test: cover reply modal and board header flows
This commit is contained in:
@@ -0,0 +1,196 @@
|
|||||||
|
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 BoardHeader from '../board-header';
|
||||||
|
|
||||||
|
(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,
|
||||||
|
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
|
||||||
|
directoriesMetadata: { title: '/all/ - Directories' } as { title?: string } | undefined,
|
||||||
|
isMobile: false,
|
||||||
|
navigateMock: vi.fn(),
|
||||||
|
offlineIconClass: 'offline',
|
||||||
|
offlineTitle: 'Board offline',
|
||||||
|
resolvedAddress: 'music-posting.eth' as string | undefined,
|
||||||
|
shouldShowSnow: false,
|
||||||
|
stableSubplebbit: {
|
||||||
|
address: 'music-posting.eth',
|
||||||
|
shortAddress: 'music-posting.eth',
|
||||||
|
title: '/mu/ - Music',
|
||||||
|
} as { address?: string; shortAddress?: string; title?: string } | undefined,
|
||||||
|
subscriptionsCount: 2,
|
||||||
|
subplebbits: {
|
||||||
|
'music-posting.eth': { address: 'music-posting.eth' },
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
useIsSubplebbitOfflineValue: {
|
||||||
|
isOffline: false,
|
||||||
|
isOnlineStatusLoading: false,
|
||||||
|
offlineIconClass: 'offline',
|
||||||
|
offlineTitle: 'Board offline',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : 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({
|
||||||
|
accounts: {
|
||||||
|
active: {
|
||||||
|
subscriptions: new Array(testState.subscriptionsCount).fill('sub'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
activeAccountId: 'active',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits', () => ({
|
||||||
|
default: (selector: (state: { subplebbits: typeof testState.subplebbits }) => unknown) =>
|
||||||
|
selector({
|
||||||
|
subplebbits: testState.subplebbits,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-stable-subplebbit', () => ({
|
||||||
|
useStableSubplebbit: () => testState.stableSubplebbit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-directories', () => ({
|
||||||
|
useDirectories: () => testState.directories,
|
||||||
|
useDirectoriesMetadata: () => testState.directoriesMetadata,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
||||||
|
useResolvedSubplebbitAddress: () => testState.resolvedAddress,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||||
|
default: () => testState.isMobile,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({
|
||||||
|
default: () => testState.useIsSubplebbitOfflineValue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../lib/snow', () => ({
|
||||||
|
shouldShowSnow: () => testState.shouldShowSnow,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../tooltip', () => ({
|
||||||
|
default: ({ content, children }: { content: string; children: React.ReactNode }) =>
|
||||||
|
createElement('span', { 'data-testid': 'tooltip', 'data-content': content }, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../generated/asset-manifest', () => ({
|
||||||
|
BANNERS: ['banner-a.png', 'banner-b.png'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
const renderHeader = async (initialEntry: string) => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(BoardHeader)));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('BoardHeader', () => {
|
||||||
|
let mathRandomSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
testState.accountComment = undefined;
|
||||||
|
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
|
||||||
|
testState.directoriesMetadata = { title: '/all/ - Directories' };
|
||||||
|
testState.isMobile = false;
|
||||||
|
testState.navigateMock.mockReset();
|
||||||
|
testState.offlineIconClass = 'offline';
|
||||||
|
testState.offlineTitle = 'Board offline';
|
||||||
|
testState.resolvedAddress = 'music-posting.eth';
|
||||||
|
testState.shouldShowSnow = false;
|
||||||
|
testState.stableSubplebbit = {
|
||||||
|
address: 'music-posting.eth',
|
||||||
|
shortAddress: 'music-posting.eth',
|
||||||
|
title: '/mu/ - Music',
|
||||||
|
};
|
||||||
|
testState.subscriptionsCount = 2;
|
||||||
|
testState.subplebbits = {
|
||||||
|
'music-posting.eth': { address: 'music-posting.eth' },
|
||||||
|
};
|
||||||
|
testState.useIsSubplebbitOfflineValue = {
|
||||||
|
isOffline: false,
|
||||||
|
isOnlineStatusLoading: false,
|
||||||
|
offlineIconClass: 'offline',
|
||||||
|
offlineTitle: 'Board offline',
|
||||||
|
};
|
||||||
|
mathRandomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
mathRandomSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the all view title and banner chrome on desktop', async () => {
|
||||||
|
await renderHeader('/all');
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('/all/ - Directories');
|
||||||
|
expect(container.querySelector('img')?.getAttribute('src')).toBe('banner-a.png');
|
||||||
|
expect(container.textContent).not.toContain('subscriptions_subtitle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a clickable subscriptions subtitle that navigates to subscription settings', async () => {
|
||||||
|
await renderHeader('/subs');
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('/subs/ - Subscriptions');
|
||||||
|
expect(container.textContent).toContain('subscriptions_subtitle:{"count":2}');
|
||||||
|
|
||||||
|
const clickableSubtitle = container.querySelector('[role="button"]');
|
||||||
|
await act(async () => {
|
||||||
|
clickableSubtitle?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings#subscriptions-settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the board title, address subtitle, and offline indicator for board routes', async () => {
|
||||||
|
testState.useIsSubplebbitOfflineValue = {
|
||||||
|
isOffline: true,
|
||||||
|
isOnlineStatusLoading: false,
|
||||||
|
offlineIconClass: 'offline',
|
||||||
|
offlineTitle: 'Board offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderHeader('/mu');
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('/mu/ - Music');
|
||||||
|
expect(container.textContent).toContain('music-posting.eth');
|
||||||
|
expect(container.querySelector('[data-testid="tooltip"]')?.getAttribute('data-content')).toBe('Board offline');
|
||||||
|
expect(container.querySelector('img')?.getAttribute('src')).toBe('banner-a.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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 CatalogFilters from '../catalog-filters';
|
||||||
|
|
||||||
|
(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 FilterItem = {
|
||||||
|
color?: string;
|
||||||
|
count: number;
|
||||||
|
enabled: boolean;
|
||||||
|
filteredCids: Set<string>;
|
||||||
|
hide: boolean;
|
||||||
|
subplebbitCounts: Map<string, number>;
|
||||||
|
subplebbitFilteredCids: Map<string, Set<string>>;
|
||||||
|
text: string;
|
||||||
|
top: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const testState = vi.hoisted(() => ({
|
||||||
|
currentSubplebbitAddress: 'music-posting.eth' as string | null,
|
||||||
|
filterItems: [] as FilterItem[],
|
||||||
|
resetCountsMock: vi.fn(),
|
||||||
|
resetFeedMock: vi.fn(),
|
||||||
|
saveAndApplyFiltersMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createFilterItem = (overrides: Partial<FilterItem> = {}): FilterItem => ({
|
||||||
|
color: '',
|
||||||
|
count: 0,
|
||||||
|
enabled: true,
|
||||||
|
filteredCids: new Set<string>(),
|
||||||
|
hide: true,
|
||||||
|
subplebbitCounts: new Map<string, number>(),
|
||||||
|
subplebbitFilteredCids: new Map<string, Set<string>>(),
|
||||||
|
text: '',
|
||||||
|
top: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCatalogFiltersState() {
|
||||||
|
return {
|
||||||
|
currentSubplebbitAddress: testState.currentSubplebbitAddress,
|
||||||
|
filterItems: testState.filterItems,
|
||||||
|
saveAndApplyFilters: testState.saveAndApplyFiltersMock,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCatalogFiltersStoreMock<T>(selector?: (state: ReturnType<typeof getCatalogFiltersState>) => T) {
|
||||||
|
const state = getCatalogFiltersState();
|
||||||
|
return selector ? selector(state) : (state as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
useCatalogFiltersStoreMock.getState = () => ({
|
||||||
|
resetCountsForCurrentSubplebbit: testState.resetCountsMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/use-catalog-filters-store', () => ({
|
||||||
|
default: useCatalogFiltersStoreMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/use-feed-reset-store', () => ({
|
||||||
|
default: (selector: (state: { reset: typeof testState.resetFeedMock }) => unknown) =>
|
||||||
|
selector({
|
||||||
|
reset: testState.resetFeedMock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../filters-protip', () => ({
|
||||||
|
default: () => createElement('div', { 'data-testid': 'filters-protip' }, 'filters-protip'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../highlight-color-picker', () => ({
|
||||||
|
default: ({ index, item, updateLocalFilterItem }: { index: number; item: FilterItem; updateLocalFilterItem: (index: number, item: FilterItem) => void }) =>
|
||||||
|
createElement(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
'data-testid': `color-picker-${index}`,
|
||||||
|
onClick: () => updateLocalFilterItem(index, { ...item, color: index === 0 ? 'red' : 'blue' }),
|
||||||
|
type: 'button',
|
||||||
|
},
|
||||||
|
item.color || 'pick',
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
const click = async (element: Element | null) => {
|
||||||
|
await act(async () => {
|
||||||
|
element?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchInput = async (element: HTMLInputElement, value: string) => {
|
||||||
|
await act(async () => {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
|
||||||
|
descriptor?.set?.call(element, value);
|
||||||
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCheckbox = async (element: HTMLInputElement | undefined) => {
|
||||||
|
await act(async () => {
|
||||||
|
element?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCatalogFilters = () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(createElement(CatalogFilters));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = async () => {
|
||||||
|
const button = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => candidate.textContent === 'filters');
|
||||||
|
await click(button ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRows = () => Array.from(container.querySelectorAll('tbody tr'));
|
||||||
|
|
||||||
|
const getTextInputs = () => Array.from(container.querySelectorAll<HTMLInputElement>('tbody input[type="text"]'));
|
||||||
|
|
||||||
|
describe('CatalogFilters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
testState.currentSubplebbitAddress = 'music-posting.eth';
|
||||||
|
testState.filterItems = [
|
||||||
|
createFilterItem({
|
||||||
|
count: 2,
|
||||||
|
subplebbitCounts: new Map([['music-posting.eth', 2]]),
|
||||||
|
subplebbitFilteredCids: new Map([['music-posting.eth', new Set(['alpha-cid'])]]),
|
||||||
|
text: 'alpha',
|
||||||
|
}),
|
||||||
|
createFilterItem({
|
||||||
|
count: 4,
|
||||||
|
hide: false,
|
||||||
|
subplebbitCounts: new Map([['music-posting.eth', 4]]),
|
||||||
|
subplebbitFilteredCids: new Map([['music-posting.eth', new Set(['beta-cid'])]]),
|
||||||
|
text: 'beta',
|
||||||
|
top: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
testState.resetCountsMock.mockReset();
|
||||||
|
testState.resetFeedMock.mockReset();
|
||||||
|
testState.saveAndApplyFiltersMock.mockReset();
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens help, closes help from the overlay, and closes the modal with Escape', async () => {
|
||||||
|
renderCatalogFilters();
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('filter_and_highlights');
|
||||||
|
|
||||||
|
await click(container.querySelector('[title="help"]'));
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="filters-protip"]')?.textContent).toBe('filters-protip');
|
||||||
|
expect(container.textContent).toContain('filter_and_highlights_help');
|
||||||
|
|
||||||
|
const overlay = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => candidate.tagName === 'DIV');
|
||||||
|
await click(overlay ?? null);
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="filters-protip"]')).toBeNull();
|
||||||
|
expect(container.textContent).toContain('filter_and_highlights');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[title="close"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a filter row with default empty values', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
renderCatalogFilters();
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
expect(getTextInputs()).toHaveLength(2);
|
||||||
|
|
||||||
|
const addButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'add');
|
||||||
|
await act(async () => {
|
||||||
|
addButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputs = getTextInputs();
|
||||||
|
expect(inputs).toHaveLength(3);
|
||||||
|
expect(inputs[2]?.value).toBe('');
|
||||||
|
|
||||||
|
const addedRowInputs = Array.from(getRows()[2]?.querySelectorAll<HTMLInputElement>('input') ?? []);
|
||||||
|
expect(addedRowInputs[0]?.checked).toBe(true);
|
||||||
|
expect(addedRowInputs[2]?.checked).toBe(true);
|
||||||
|
expect(addedRowInputs[3]?.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders, edits, and saves non-empty filters via the document Enter shortcut', async () => {
|
||||||
|
renderCatalogFilters();
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('x2');
|
||||||
|
expect(container.textContent).toContain('x4');
|
||||||
|
|
||||||
|
const initialRows = getRows();
|
||||||
|
const moveUpButton = initialRows[1]?.querySelector('[role="button"]');
|
||||||
|
await click(moveUpButton ?? null);
|
||||||
|
|
||||||
|
const reorderedInputs = getTextInputs();
|
||||||
|
expect(reorderedInputs[0]?.value).toBe('beta');
|
||||||
|
expect(reorderedInputs[1]?.value).toBe('alpha');
|
||||||
|
|
||||||
|
const deleteButton = getRows()[1]?.querySelectorAll('[role="button"]')[1];
|
||||||
|
await click(deleteButton ?? null);
|
||||||
|
|
||||||
|
expect(getTextInputs()).toHaveLength(1);
|
||||||
|
|
||||||
|
const firstRow = getRows()[0];
|
||||||
|
const firstRowInputs = firstRow ? Array.from(firstRow.querySelectorAll<HTMLInputElement>('input')) : [];
|
||||||
|
const enabledCheckbox = firstRowInputs[0];
|
||||||
|
const textInput = firstRowInputs[1];
|
||||||
|
const hideCheckbox = firstRowInputs[2];
|
||||||
|
const topCheckbox = firstRowInputs[3];
|
||||||
|
|
||||||
|
expect(textInput?.value).toBe('beta');
|
||||||
|
|
||||||
|
await toggleCheckbox(enabledCheckbox);
|
||||||
|
await click(container.querySelector('[data-testid="color-picker-0"]'));
|
||||||
|
await toggleCheckbox(hideCheckbox);
|
||||||
|
await toggleCheckbox(topCheckbox);
|
||||||
|
await dispatchInput(textInput, 'beta-updated');
|
||||||
|
|
||||||
|
const addButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'add');
|
||||||
|
await click(addButton ?? null);
|
||||||
|
|
||||||
|
expect(getTextInputs()).toHaveLength(2);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testState.saveAndApplyFiltersMock).toHaveBeenCalledTimes(1);
|
||||||
|
const savedFilters = testState.saveAndApplyFiltersMock.mock.calls[0]?.[0] as FilterItem[] | undefined;
|
||||||
|
expect(savedFilters).toHaveLength(1);
|
||||||
|
expect(savedFilters?.[0]).toMatchObject({
|
||||||
|
color: 'red',
|
||||||
|
enabled: false,
|
||||||
|
hide: true,
|
||||||
|
text: 'beta-updated',
|
||||||
|
top: false,
|
||||||
|
});
|
||||||
|
expect(savedFilters?.[0]).not.toHaveProperty('id');
|
||||||
|
expect(testState.resetCountsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testState.resetFeedMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container.querySelector('[title="close"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
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 CatalogRow, { CatalogPostMedia } from '../catalog-row';
|
||||||
|
|
||||||
|
(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 = {
|
||||||
|
author?: {
|
||||||
|
address?: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
cid: string;
|
||||||
|
content?: string;
|
||||||
|
link?: string;
|
||||||
|
linkHeight?: number;
|
||||||
|
linkWidth?: number;
|
||||||
|
locked?: boolean;
|
||||||
|
parentCid?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
postCid?: string;
|
||||||
|
removed?: boolean;
|
||||||
|
replyCount?: number;
|
||||||
|
spoiler?: boolean;
|
||||||
|
subplebbitAddress?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const testState = vi.hoisted(() => ({
|
||||||
|
directories: [{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }] as Array<{
|
||||||
|
address: string;
|
||||||
|
features?: Record<string, unknown>;
|
||||||
|
title?: string;
|
||||||
|
}>,
|
||||||
|
gifFrameStatus: 'idle' as 'failed' | 'idle' | 'ready',
|
||||||
|
gifFrameUrl: undefined as string | undefined,
|
||||||
|
hiddenCids: new Set<string>(),
|
||||||
|
imageSize: 'Small' as 'Large' | 'Small',
|
||||||
|
linkCount: 0,
|
||||||
|
matchedFilters: new Map<string, string>(),
|
||||||
|
mediaInfoByLink: {} as Record<string, { patternThumbnailUrl?: string; thumbnail?: string; type: string; url: string }>,
|
||||||
|
replies: [] as TestComment[],
|
||||||
|
roleByAddress: {} as Record<string, { commentAuthorRole?: string; isCommentAuthorMod: boolean }>,
|
||||||
|
showOPComment: true,
|
||||||
|
showSnow: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getCatalogFiltersState() {
|
||||||
|
return {
|
||||||
|
matchedFilters: testState.matchedFilters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||||
|
useReplies: ({ comment }: { comment?: TestComment }) => ({
|
||||||
|
replies: comment ? testState.replies : [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({
|
||||||
|
default: {
|
||||||
|
createInstance: () => ({
|
||||||
|
entries: vi.fn().mockResolvedValue([]),
|
||||||
|
getItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@floating-ui/react', () => ({
|
||||||
|
offset: () => ({}),
|
||||||
|
size: () => ({}),
|
||||||
|
useFloating: () => ({
|
||||||
|
floatingStyles: {},
|
||||||
|
refs: {
|
||||||
|
floating: { current: null },
|
||||||
|
reference: { current: null },
|
||||||
|
setFloating: () => undefined,
|
||||||
|
setReference: () => undefined,
|
||||||
|
},
|
||||||
|
update: () => undefined,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../lib/get-short-address', () => ({
|
||||||
|
default: () => 'mu',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../lib/snow', () => ({
|
||||||
|
shouldShowSnow: () => testState.showSnow,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../lib/utils/time-utils', () => ({
|
||||||
|
getFormattedTimeAgo: (timestamp?: number) => `ago:${timestamp}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../lib/utils/post-menu-props', () => ({
|
||||||
|
selectPostMenuProps: (post?: TestComment) => ({ cid: post?.cid }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-directories', () => ({
|
||||||
|
findDirectoryByAddress: (directories: typeof testState.directories, address?: string) => directories.find((entry) => entry.address === address),
|
||||||
|
normalizeBoardAddress: (address: string) => address,
|
||||||
|
useDirectories: () => testState.directories,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/use-catalog-filters-store', () => ({
|
||||||
|
default: <T,>(selector?: (state: ReturnType<typeof getCatalogFiltersState>) => T) => {
|
||||||
|
const state = getCatalogFiltersState();
|
||||||
|
return selector ? selector(state) : (state as T);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/use-catalog-style-store', () => ({
|
||||||
|
default: () => ({
|
||||||
|
imageSize: testState.imageSize,
|
||||||
|
showOPComment: testState.showOPComment,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-author-privileges', () => ({
|
||||||
|
default: ({ commentAuthorAddress }: { commentAuthorAddress?: string }) =>
|
||||||
|
testState.roleByAddress[commentAuthorAddress || ''] || {
|
||||||
|
commentAuthorRole: undefined,
|
||||||
|
isCommentAuthorMod: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-comment-media-info', () => ({
|
||||||
|
useCommentMediaInfo: (link?: string) => (link ? testState.mediaInfoByLink[link] : undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-count-links-in-replies', () => ({
|
||||||
|
default: () => testState.linkCount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({
|
||||||
|
default: () => ({
|
||||||
|
frameUrl: testState.gifFrameUrl,
|
||||||
|
status: testState.gifFrameStatus,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-hide', () => ({
|
||||||
|
default: ({ cid }: { cid: string }) => ({
|
||||||
|
hidden: testState.hiddenCids.has(cid),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../post-desktop/post-menu-desktop', () => ({
|
||||||
|
default: ({ postMenu }: { postMenu: { cid?: string } }) => createElement('span', { 'data-testid': `post-menu-${postMenu.cid}` }, 'menu'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
const flushEffects = async (count = 3) => {
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithRouter = async (element: React.ReactNode, initialEntry = '/mu/catalog') => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, element));
|
||||||
|
});
|
||||||
|
await flushEffects();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CatalogRow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
testState.directories = [{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }];
|
||||||
|
testState.gifFrameStatus = 'idle';
|
||||||
|
testState.gifFrameUrl = undefined;
|
||||||
|
testState.hiddenCids = new Set<string>();
|
||||||
|
testState.imageSize = 'Small';
|
||||||
|
testState.linkCount = 0;
|
||||||
|
testState.matchedFilters = new Map<string, string>();
|
||||||
|
testState.mediaInfoByLink = {};
|
||||||
|
testState.replies = [];
|
||||||
|
testState.roleByAddress = {};
|
||||||
|
testState.showOPComment = true;
|
||||||
|
testState.showSnow = false;
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders gif frames with matched filter borders and falls back to deleted media on load errors', async () => {
|
||||||
|
testState.gifFrameStatus = 'ready';
|
||||||
|
testState.gifFrameUrl = 'https://cdn.example/frame.png';
|
||||||
|
testState.matchedFilters = new Map([['post-1', 'red']]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
createElement(CatalogPostMedia, {
|
||||||
|
cid: 'post-1',
|
||||||
|
commentMediaInfo: { type: 'gif', url: 'https://example.com/source.gif' },
|
||||||
|
linkHeight: 200,
|
||||||
|
linkWidth: 400,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement | null;
|
||||||
|
const frameImage = container.querySelector<HTMLImageElement>('img[src="https://cdn.example/frame.png"]');
|
||||||
|
|
||||||
|
expect(wrapper?.style.border).toContain('red');
|
||||||
|
expect(frameImage).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
frameImage?.dispatchEvent(new Event('error', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector<HTMLImageElement>('img[src="assets/filedeleted-res.gif"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders audio players and video first-frame fallbacks for media without thumbnails', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
createElement(CatalogPostMedia, {
|
||||||
|
cid: 'audio-post',
|
||||||
|
commentMediaInfo: { type: 'audio', url: 'https://example.com/file.mp3' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector<HTMLAudioElement>('audio[src="https://example.com/file.mp3"]')).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
createElement(CatalogPostMedia, {
|
||||||
|
cid: 'video-post',
|
||||||
|
commentMediaInfo: { type: 'video', url: 'https://example.com/file.mp4' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector<HTMLVideoElement>('video[src="https://example.com/file.mp4#t=0.001"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders media posts with board links, counts, and hover previews in all view', async () => {
|
||||||
|
testState.directories = [{ address: 'music-posting.eth', features: { requirePostLinkIsMedia: true }, title: '/mu/ - Music' }];
|
||||||
|
testState.linkCount = 2;
|
||||||
|
testState.mediaInfoByLink['https://example.com/media.png'] = { type: 'image', url: 'https://example.com/media.png' };
|
||||||
|
testState.replies = [
|
||||||
|
{
|
||||||
|
author: { address: 'author-2', displayName: 'Bob' },
|
||||||
|
cid: 'reply-1',
|
||||||
|
timestamp: 200,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testState.roleByAddress = {
|
||||||
|
'author-1': { commentAuthorRole: 'Owner', isCommentAuthorMod: true },
|
||||||
|
'author-2': { commentAuthorRole: 'Janitor', isCommentAuthorMod: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const post: TestComment = {
|
||||||
|
author: { address: 'author-1', displayName: 'Alice' },
|
||||||
|
cid: 'post-1',
|
||||||
|
content: 'Hello **world**',
|
||||||
|
link: 'https://example.com/media.png',
|
||||||
|
linkHeight: 120,
|
||||||
|
linkWidth: 200,
|
||||||
|
locked: true,
|
||||||
|
pinned: true,
|
||||||
|
replyCount: 5,
|
||||||
|
subplebbitAddress: 'music-posting.eth',
|
||||||
|
timestamp: 100,
|
||||||
|
title: 'Thread title',
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderWithRouter(createElement(CatalogRow, { row: [post] }), '/all/catalog');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const postLink = document.body.querySelector<HTMLAnchorElement>('a[href="/mu/thread/post-1"]');
|
||||||
|
expect(postLink).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain('R: 5');
|
||||||
|
expect(container.textContent).toContain('/ I: 2');
|
||||||
|
expect(container.querySelector('[data-testid="post-menu-post-1"]')?.textContent).toBe('menu');
|
||||||
|
|
||||||
|
const previewTrigger = document.body.querySelector('a[href="/mu/thread/post-1"] > div');
|
||||||
|
await act(async () => {
|
||||||
|
previewTrigger?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||||
|
vi.advanceTimersByTime(260);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Thread title by Alice ## Board Owner');
|
||||||
|
expect(document.body.textContent).toContain('to p/mu');
|
||||||
|
expect(document.body.textContent).toContain('last_reply_by Bob ## Board Janitor');
|
||||||
|
expect(document.body.textContent).toContain('ago:100');
|
||||||
|
expect(document.body.textContent).toContain('ago:200');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hidden and text-only threads with canonical board thread links', async () => {
|
||||||
|
testState.hiddenCids = new Set(['hidden-1']);
|
||||||
|
testState.showOPComment = false;
|
||||||
|
|
||||||
|
const posts: TestComment[] = [
|
||||||
|
{
|
||||||
|
author: { address: 'hidden-author', displayName: 'Ghost' },
|
||||||
|
cid: 'hidden-1',
|
||||||
|
content: 'hidden text',
|
||||||
|
link: 'https://example.com/hidden.png',
|
||||||
|
subplebbitAddress: 'music-posting.eth',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
author: { address: 'text-author', displayName: 'Anon' },
|
||||||
|
cid: 'text-1',
|
||||||
|
content: 'Plain thread body',
|
||||||
|
replyCount: 1,
|
||||||
|
subplebbitAddress: 'music-posting.eth',
|
||||||
|
title: 'Text title',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await renderWithRouter(createElement(CatalogRow, { row: posts }), '/mu/catalog');
|
||||||
|
|
||||||
|
const links = Array.from(document.body.querySelectorAll<HTMLAnchorElement>('a')).map((link) => link.getAttribute('href'));
|
||||||
|
expect(links).toContain('/mu/thread/hidden-1');
|
||||||
|
expect(links).toContain('/mu/thread/text-1');
|
||||||
|
expect(container.textContent).toContain('(hidden)');
|
||||||
|
expect(container.textContent).toContain('Text title: Plain thread body');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
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 ReplyModal from '../reply-modal';
|
||||||
|
|
||||||
|
(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' } } as { author?: { displayName?: string } },
|
||||||
|
closeModalMock: vi.fn(),
|
||||||
|
currentTime: 10_000,
|
||||||
|
directoryByAddress: {
|
||||||
|
'music-posting.eth': {
|
||||||
|
address: 'music-posting.eth',
|
||||||
|
features: {},
|
||||||
|
},
|
||||||
|
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
||||||
|
handleUploadMock: vi.fn(),
|
||||||
|
isMobile: false,
|
||||||
|
isUploading: false,
|
||||||
|
openEmpty: false,
|
||||||
|
publishReplyMock: vi.fn(),
|
||||||
|
quoteInsertNumber: undefined as number | undefined,
|
||||||
|
quoteInsertRequestId: 0,
|
||||||
|
quoteInsertSelectedText: '',
|
||||||
|
replyIndex: undefined as number | undefined,
|
||||||
|
resetPublishReplyOptionsMock: vi.fn(),
|
||||||
|
selectedText: 'selected text',
|
||||||
|
setAccountMock: vi.fn(),
|
||||||
|
setPublishReplyOptionsMock: vi.fn(),
|
||||||
|
springStartMock: vi.fn(),
|
||||||
|
showUploadControls: true,
|
||||||
|
updatedAt: undefined as number | undefined,
|
||||||
|
uploadComplete: undefined as ((url: string) => void) | undefined,
|
||||||
|
uploadedFileName: null as string | null,
|
||||||
|
uploadMode: 'always',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record<string, unknown> }) =>
|
||||||
|
createElement('span', { 'data-testid': `trans-${i18nKey}` }, `${i18nKey}:${JSON.stringify(values || {})}`),
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => {
|
||||||
|
if (!options) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
if (typeof options.no !== 'undefined') {
|
||||||
|
return `${key}:${options.no}`;
|
||||||
|
}
|
||||||
|
if (typeof options.length !== 'undefined') {
|
||||||
|
return `${key}:${options.length}`;
|
||||||
|
}
|
||||||
|
return `${key}:${JSON.stringify(options)}`;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||||
|
setAccount: (account: unknown) => testState.setAccountMock(account),
|
||||||
|
useAccount: () => testState.account,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-stable-subplebbit', () => ({
|
||||||
|
useSubplebbitField: () => testState.updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/use-selected-text-store', () => ({
|
||||||
|
default: () => ({
|
||||||
|
selectedText: testState.selectedText,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/use-reply-modal-store', () => ({
|
||||||
|
default: <T,>(selector?: (state: { openEmpty: boolean; quoteInsertNumber?: number; quoteInsertRequestId: number; quoteInsertSelectedText: string }) => T) => {
|
||||||
|
const state = {
|
||||||
|
openEmpty: testState.openEmpty,
|
||||||
|
quoteInsertNumber: testState.quoteInsertNumber,
|
||||||
|
quoteInsertRequestId: testState.quoteInsertRequestId,
|
||||||
|
quoteInsertSelectedText: testState.quoteInsertSelectedText,
|
||||||
|
};
|
||||||
|
return selector ? selector(state) : (state as T);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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('../../../hooks/use-directories', () => ({
|
||||||
|
useDirectoryByAddress: (address: string) => testState.directoryByAddress[address],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-publish-reply', () => ({
|
||||||
|
default: () => ({
|
||||||
|
publishReply: testState.publishReplyMock,
|
||||||
|
replyIndex: testState.replyIndex,
|
||||||
|
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
||||||
|
setPublishReplyOptions: (options: Record<string, unknown>) => testState.setPublishReplyOptionsMock(options),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||||
|
default: () => testState.isMobile,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-current-time', () => ({
|
||||||
|
useCurrentTime: () => testState.currentTime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-file-upload', () => ({
|
||||||
|
useFileUpload: ({ onUploadComplete }: { onUploadComplete: (url: string) => void }) => {
|
||||||
|
testState.uploadComplete = onUploadComplete;
|
||||||
|
return {
|
||||||
|
handleUpload: testState.handleUploadMock,
|
||||||
|
isUploading: testState.isUploading,
|
||||||
|
uploadedFileName: testState.uploadedFileName,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@react-spring/web', async () => {
|
||||||
|
const React = await vi.importActual<typeof import('react')>('react');
|
||||||
|
|
||||||
|
return {
|
||||||
|
animated: {
|
||||||
|
div: React.forwardRef(({ style, ...props }: any, ref) => React.createElement('div', { ...props, ref, style: { touchAction: style?.touchAction } })),
|
||||||
|
},
|
||||||
|
useSpring: () => [
|
||||||
|
{
|
||||||
|
x: { get: () => 120 },
|
||||||
|
y: { get: () => 80 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: testState.springStartMock,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@use-gesture/react', () => ({
|
||||||
|
useDrag: () => () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../lib/utils/time-utils', () => ({
|
||||||
|
getFormattedTimeAgo: (time: number) => `ago:${time}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 renderReplyModal = async (initialEntry = '/mu/thread/post-1') => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
{ initialEntries: [initialEntry] },
|
||||||
|
createElement(ReplyModal, {
|
||||||
|
closeModal: testState.closeModalMock,
|
||||||
|
parentCid: 'parent-cid',
|
||||||
|
parentNumber: 42,
|
||||||
|
postCid: 'post-cid',
|
||||||
|
scrollY: 120,
|
||||||
|
showReplyModal: true,
|
||||||
|
subplebbitAddress: 'music-posting.eth',
|
||||||
|
threadNumber: 42,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushEffects();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rerenderReplyModal = async (initialEntry = '/mu/thread/post-1') => {
|
||||||
|
await renderReplyModal(initialEntry);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||||
|
await act(async () => {
|
||||||
|
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
||||||
|
descriptor?.set?.call(element, value);
|
||||||
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clickButtonByText = async (text: string) => {
|
||||||
|
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
|
||||||
|
await act(async () => {
|
||||||
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ReplyModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
testState.account = { author: { displayName: 'Alice' } };
|
||||||
|
testState.closeModalMock.mockReset();
|
||||||
|
testState.currentTime = 10_000;
|
||||||
|
testState.directoryByAddress = {
|
||||||
|
'music-posting.eth': {
|
||||||
|
address: 'music-posting.eth',
|
||||||
|
features: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
testState.handleUploadMock.mockReset();
|
||||||
|
testState.isMobile = false;
|
||||||
|
testState.isUploading = false;
|
||||||
|
testState.openEmpty = false;
|
||||||
|
testState.publishReplyMock.mockReset();
|
||||||
|
testState.quoteInsertNumber = undefined;
|
||||||
|
testState.quoteInsertRequestId = 0;
|
||||||
|
testState.quoteInsertSelectedText = '';
|
||||||
|
testState.replyIndex = undefined;
|
||||||
|
testState.resetPublishReplyOptionsMock.mockReset();
|
||||||
|
testState.selectedText = 'selected text';
|
||||||
|
testState.setAccountMock.mockReset();
|
||||||
|
testState.setPublishReplyOptionsMock.mockReset();
|
||||||
|
testState.springStartMock.mockReset();
|
||||||
|
testState.showUploadControls = true;
|
||||||
|
testState.updatedAt = undefined;
|
||||||
|
testState.uploadComplete = undefined;
|
||||||
|
testState.uploadedFileName = null;
|
||||||
|
testState.uploadMode = 'always';
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes quoted content, display name, upload controls, and offline warning on board routes', async () => {
|
||||||
|
testState.updatedAt = 1_000;
|
||||||
|
|
||||||
|
await renderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
|
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
|
||||||
|
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||||
|
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||||||
|
|
||||||
|
expect(nameInput?.value).toBe('Alice');
|
||||||
|
expect(linkInput?.getAttribute('placeholder')).toContain('Link');
|
||||||
|
expect(textarea?.value).toBe('>>42\nselected text');
|
||||||
|
expect(container.textContent).toContain('choose_file');
|
||||||
|
expect(container.textContent).toContain('Spoiler?');
|
||||||
|
expect(container.textContent).toContain('warning');
|
||||||
|
expect(container.textContent).toContain('posts_last_synced_info:{"time":"ago:1000"}');
|
||||||
|
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: '>>42\nselected text' });
|
||||||
|
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alice' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates empty and invalid replies, then publishes once the payload is valid', async () => {
|
||||||
|
testState.openEmpty = true;
|
||||||
|
testState.selectedText = '';
|
||||||
|
|
||||||
|
await renderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
|
await clickButtonByText('post');
|
||||||
|
expect(container.textContent).toContain('error: empty_comment_alert');
|
||||||
|
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||||
|
const spoilerCheckbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||||
|
await dispatchInput(linkInput, 'not-a-url');
|
||||||
|
await act(async () => {
|
||||||
|
spoilerCheckbox?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
await clickButtonByText('post');
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('error: invalid_url_alert');
|
||||||
|
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ spoiler: true });
|
||||||
|
|
||||||
|
await dispatchInput(linkInput, 'https://example.com/file.png');
|
||||||
|
await clickButtonByText('post');
|
||||||
|
|
||||||
|
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://example.com/file.png' });
|
||||||
|
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates account state, applies upload completions, and closes once publishing succeeds', async () => {
|
||||||
|
await renderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
|
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
|
||||||
|
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||||
|
|
||||||
|
await dispatchInput(nameInput, 'Alicia');
|
||||||
|
expect(testState.setAccountMock).toHaveBeenCalledWith({
|
||||||
|
author: { displayName: 'Alicia' },
|
||||||
|
});
|
||||||
|
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alicia' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
testState.uploadComplete?.('https://cdn.example/uploaded.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(linkInput?.value).toBe('https://cdn.example/uploaded.png');
|
||||||
|
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://cdn.example/uploaded.png' });
|
||||||
|
|
||||||
|
testState.replyIndex = 3;
|
||||||
|
await rerenderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
|
expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testState.closeModalMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts quote requests only once and keeps the textarea content stable across rerenders', async () => {
|
||||||
|
testState.isMobile = true;
|
||||||
|
testState.openEmpty = true;
|
||||||
|
testState.selectedText = 'Existing line';
|
||||||
|
|
||||||
|
await renderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
|
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||||||
|
expect(textarea?.value).toBe('Existing line');
|
||||||
|
|
||||||
|
testState.quoteInsertNumber = 77;
|
||||||
|
testState.quoteInsertRequestId = 1;
|
||||||
|
testState.quoteInsertSelectedText = 'Quoted line';
|
||||||
|
await rerenderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
|
expect(textarea?.value).toBe('Existing line\n>>77\nQuoted line\n');
|
||||||
|
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
|
||||||
|
content: 'Existing line\n>>77\nQuoted line\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
await rerenderReplyModal('/mu/thread/post-1');
|
||||||
|
expect(textarea?.value).toBe('Existing line\n>>77\nQuoted line\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses file-link placeholder defaults in all view and hides board-specific warnings or spoiler controls when disabled', async () => {
|
||||||
|
testState.directoryByAddress = {
|
||||||
|
'music-posting.eth': {
|
||||||
|
address: 'music-posting.eth',
|
||||||
|
features: { noSpoilerReplies: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderReplyModal('/all/thread/post-1');
|
||||||
|
|
||||||
|
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||||||
|
expect(linkInput?.getAttribute('placeholder')).toContain('Link_to_file');
|
||||||
|
expect(container.textContent).not.toContain('warning');
|
||||||
|
expect(container.textContent).not.toContain('Spoiler?');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user