Files
5chan/src/components/reply-modal/__tests__/reply-modal.test.tsx
T
Tommaso CasaburiandGitHub 6cb28b0e4f fix(youtube thumbnails): prefer best available image
Prefer the best available YouTube thumbnail, fall back cleanly when high-resolution images are unavailable, and keep the React Doctor PR gate scoped to newly introduced issues.
2026-06-13 13:39:22 +07:00

1291 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { OEKAKI_WEB_WARNING_TEXT } from '../../../lib/oekaki/oekaki-copy';
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
(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: { address: 'alice.eth', displayName: 'Alice' } } as { author?: { address?: string; displayName?: string } },
closeModalMock: vi.fn(),
directoryByAddress: {
'music-posting.eth': {
address: 'music-posting.eth',
features: {},
title: '/mu/ - Music',
},
} as Record<string, { address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string }>,
directoryListsByCode: {} as Record<
string,
{
boards: Array<{ address: string; features?: Record<string, unknown>; publicKey?: string }>;
directoryCode: string;
features?: Record<string, unknown>;
title?: string;
}
>,
handleUploadMock: vi.fn(),
uploadFileMock: vi.fn(),
isMobile: false,
mediaHostingRuntime: 'web' as 'web' | 'android' | 'electron',
isResolvingExternalQuotes: false,
isUploading: false,
navigateMock: vi.fn(),
offlineTitle: '' as string | false,
offlineStates: {} as Record<string, { isOffline: boolean; isOnlineStatusLoading: boolean; offlineTitle: string | false }>,
offlineStatusLoading: false,
offlineWarningVisible: false,
openEmpty: false,
publishReplyMock: vi.fn(),
publishReplyError: null as string | null,
publishReplyStateMessage: null as string | null,
quoteInsertNumber: undefined as number | undefined,
quoteInsertRequestId: 0,
quoteInsertSelectedText: '',
dragHandler: undefined as ((state: { active: boolean; event: Pick<Event, 'preventDefault'>; offset: [number, number] }) => void) | undefined,
replyIndex: undefined as number | undefined,
resetPublishReplyOptionsMock: vi.fn(),
resolvedCommunityAddress: undefined as string | undefined,
rolesByCommunity: {} as Record<string, Record<string, { role?: string }>>,
selectedText: 'selected text',
setAccountMock: vi.fn(),
setPublishReplyOptionsMock: vi.fn(),
springStartMock: vi.fn(),
useSpringMock: vi.fn(),
communities: {
'music-posting.eth': {
address: 'music-posting.eth',
},
} as Record<string, { address: string }>,
fetchMock: vi.fn(),
showUploadControls: true,
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('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => testState.navigateMock,
};
});
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
setAccount: (account: unknown) => testState.setAccountMock(account),
useAccount: () => testState.account,
useCommunity: (options?: { community?: { name?: string; publicKey?: string } }) => {
const communityKey = options?.community?.name ?? options?.community?.publicKey;
return communityKey ? testState.communities[communityKey] : undefined;
},
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/communities', () => ({
default: <T,>(selector: (state: { communities: typeof testState.communities }) => T) =>
selector({
communities: testState.communities,
}),
}));
vi.mock('../../../hooks/use-is-community-offline', () => ({
default: (community?: { address?: string }) =>
(community?.address ? testState.offlineStates[community.address] : undefined) || {
isOffline: testState.offlineWarningVisible,
isOnlineStatusLoading: testState.offlineStatusLoading,
offlineTitle: testState.offlineTitle,
},
}));
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', () => ({
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
getShowUploadControls: () => testState.showUploadControls,
isWebRuntime: () => testState.mediaHostingRuntime === 'web',
}));
vi.mock('../../../stores/use-media-hosting-store', () => ({
default: (selector: (state: { uploadMode: string }) => unknown) =>
selector({
uploadMode: testState.uploadMode,
}),
}));
vi.mock('../../../hooks/use-directories', () => ({
findDirectoryByAddress: (directories: Array<{ address: string; features?: Record<string, unknown>; title?: string }>, address: string | undefined) =>
directories.find((entry) => entry.address === address),
useDirectories: () => Object.values(testState.directoryByAddress),
useDirectoryByAddress: (address: string) => testState.directoryByAddress[address],
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
}));
vi.mock('../../../hooks/use-directory-entry', async () => {
const actual = await vi.importActual<typeof import('../../../hooks/use-directory-entry')>('../../../hooks/use-directory-entry');
return {
...actual,
useDirectoryEntry: (address: string | undefined, directoryCodeHint?: string) => {
const list =
testState.directoryListsByCode[directoryCodeHint ?? ''] ??
Object.values(testState.directoryListsByCode).find((candidate) => candidate.boards.some((board) => board.address === address));
return actual.getDirectoryEntryForAddress({
address,
directories: Object.values(testState.directoryByAddress),
directoryCodeHint,
list,
});
},
};
});
vi.mock('../../../hooks/use-community-identifiers', () => ({
useCommunityIdentifier: (address?: string) => (address ? { name: address } : undefined),
}));
vi.mock('../../../hooks/use-resolved-community-address', () => ({
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
}));
vi.mock('../../../hooks/use-stable-community', () => ({
useCommunityField: <T,>(communityAddress: string | undefined, selector: (community?: { roles?: Record<string, { role?: string }> }) => T) =>
selector(communityAddress ? { roles: testState.rolesByCommunity[communityAddress] } : undefined),
}));
vi.mock('../../../hooks/use-publish-reply', async () => {
const React = await vi.importActual<typeof import('react')>('react');
return {
default: () => {
const [, forceUpdate] = React.useReducer((value: number) => value + 1, 0);
return {
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
publishReply: (options?: Record<string, unknown>) => {
if (options) {
testState.setPublishReplyOptionsMock(options);
}
const result = testState.publishReplyMock(options);
forceUpdate();
return result;
},
publishReplyError: testState.publishReplyError,
publishReplyStateMessage: testState.publishReplyStateMessage,
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-file-upload', () => ({
useFileUpload: ({ onUploadComplete }: { onUploadComplete: (url: string) => void }) => {
testState.uploadComplete = onUploadComplete;
return {
handleUpload: testState.handleUploadMock,
uploadFile: testState.uploadFileMock,
isUploading: testState.isUploading,
uploadedFileName: testState.uploadedFileName,
};
},
}));
vi.mock('../../loading-ellipsis/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
}));
vi.mock('../../../lib/mathjax/mathjax-typeset', () => ({
clearMathElement: () => undefined,
preloadMathJax: () => undefined,
typesetMathElement: () => Promise.resolve(),
}));
vi.mock('lodash/debounce', () => ({
default: <T extends (...args: any[]) => void>(fn: T, wait = 0) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const wrapped = ((...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => fn(...args), wait);
}) as T & { cancel: () => void };
wrapped.cancel = () => {
if (timeout) {
clearTimeout(timeout);
}
timeout = undefined;
};
return wrapped;
},
}));
vi.mock('@react-spring/web', async () => {
const React = await vi.importActual<typeof import('react')>('react');
const normalizeStyle = (style: Record<string, unknown> | undefined) =>
style
? Object.fromEntries(
Object.entries(style).map(([key, value]) => [
key,
typeof value === 'object' && value !== null && 'get' in value && typeof (value as { get: unknown }).get === 'function'
? (value as { get: () => unknown }).get()
: value,
]),
)
: undefined;
return {
animated: {
div: React.forwardRef(({ style, ...props }: any, ref) => React.createElement('div', { ...props, ref, style: normalizeStyle(style) })),
},
useSpring: testState.useSpringMock.mockImplementation(() => [
{
left: { get: () => 120 },
top: { get: () => 80 },
},
{
start: testState.springStartMock,
},
]),
};
});
vi.mock('@use-gesture/react', () => ({
useDrag: (handler: (state: { active: boolean; event: Pick<Event, 'preventDefault'>; offset: [number, number] }) => void) => {
testState.dragHandler = handler;
return () => ({});
},
}));
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', communityAddress = 'music-posting.eth') => {
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,
communityAddress,
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 waitForOptionsValidation = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, POST_OPTIONS_VALIDATION_DELAY_MS + 20));
});
};
const waitForContentLengthValidation = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1020));
});
};
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: { address: 'alice.eth', displayName: 'Alice' } };
testState.closeModalMock.mockReset();
testState.directoryByAddress = {
'music-posting.eth': {
address: 'music-posting.eth',
features: {},
title: '/mu/ - Music',
},
'politically-incorrect.bso': {
address: 'politically-incorrect.bso',
directoryCode: 'pol',
features: { hasFlags: true },
title: '/pol/ - Politically Incorrect',
},
'international-nsfw.bso': {
address: 'international-nsfw.bso',
directoryCode: 'bant',
features: { hasFlags: true },
title: '/bant/ - International/Random',
},
'international-sfw.bso': {
address: 'international-sfw.bso',
directoryCode: 'int',
features: { hasFlags: true },
title: '/int/ - International',
},
'sports-posting.bso': {
address: 'sports-posting.bso',
directoryCode: 'sp',
features: { hasFlags: true },
title: '/sp/ - Sports',
},
'random-nsfw.bso': {
address: 'random-nsfw.bso',
features: {},
title: '/b/ - Random',
},
'silly-stuff.bso': {
address: 'silly-stuff.bso',
features: {},
title: '/s5s/ - Silly Stuff',
},
'traditional-games.bso': {
address: 'traditional-games.bso',
features: {},
title: '/tg/ - Traditional Games',
},
};
testState.directoryListsByCode = {};
testState.handleUploadMock.mockReset();
testState.uploadFileMock.mockReset();
testState.isMobile = false;
testState.isResolvingExternalQuotes = false;
testState.isUploading = false;
testState.navigateMock.mockReset();
testState.offlineTitle = '';
testState.offlineStates = {};
testState.offlineStatusLoading = false;
testState.offlineWarningVisible = false;
testState.openEmpty = false;
testState.publishReplyMock.mockReset();
testState.publishReplyError = null;
testState.publishReplyStateMessage = null;
testState.quoteInsertNumber = undefined;
testState.quoteInsertRequestId = 0;
testState.quoteInsertSelectedText = '';
testState.dragHandler = undefined;
testState.replyIndex = undefined;
testState.resetPublishReplyOptionsMock.mockReset();
testState.resolvedCommunityAddress = undefined;
testState.rolesByCommunity = {};
testState.selectedText = 'selected text';
testState.setAccountMock.mockReset();
testState.setPublishReplyOptionsMock.mockReset();
testState.springStartMock.mockReset();
testState.useSpringMock.mockReset();
testState.useSpringMock.mockImplementation(() => [
{
left: { get: () => 120 },
top: { get: () => 80 },
},
{
start: testState.springStartMock,
},
]);
testState.communities = {
'music-posting.eth': {
address: 'music-posting.eth',
},
'traditional-games.bso': {
address: 'traditional-games.bso',
},
};
testState.fetchMock.mockReset();
testState.fetchMock.mockResolvedValue({
headers: {
get: (name: string) => (name.toLowerCase() === 'content-length' ? '12345' : null),
},
ok: true,
});
vi.stubGlobal('fetch', testState.fetchMock);
testState.showUploadControls = true;
testState.uploadComplete = undefined;
testState.uploadedFileName = null;
testState.uploadMode = 'always';
testState.mediaHostingRuntime = 'web';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
vi.unstubAllGlobals();
});
it('initializes quoted content, display name, upload controls, and shared offline warning on board routes', async () => {
testState.offlineTitle = 'posts_last_synced_info:{"time":"ago:1000"}';
testState.offlineWarningVisible = true;
await renderReplyModal('/mu/thread/post-1');
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
expect(optionsInput).toBeTruthy();
expect(linkInput).toBeTruthy();
expect(textarea).toBeTruthy();
expect(nameInput?.value).toBe('Alice');
expect(optionsInput?.getAttribute('placeholder')).toBe('Options');
expect(linkInput?.getAttribute('placeholder')).toContain('Link');
expect(textarea?.value).toBe('>>42\nselected text');
expect(Boolean(optionsInput!.compareDocumentPosition(textarea!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
expect(Boolean(textarea!.compareDocumentPosition(linkInput!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
expect(container.textContent).toContain('choose_file');
expect(container.textContent).toContain('Spoiler?');
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('shows Oekaki draw controls on /i/ replies', async () => {
testState.directoryByAddress['oekaki-posting.bso'] = {
address: 'oekaki-posting.bso',
directoryCode: 'i',
features: { requirePostLink: true, requirePostLinkIsMedia: true },
title: '/i/ - Oekaki',
};
testState.communities['oekaki-posting.bso'] = { address: 'oekaki-posting.bso' };
await renderReplyModal('/i/thread/post-1', 'oekaki-posting.bso');
expect(container.textContent).toContain('Size');
expect(container.textContent).toContain('Replay');
expect(Array.from(container.querySelectorAll('span')).some((span) => span.textContent === '×')).toBe(true);
expect(container.textContent).toContain(OEKAKI_WEB_WARNING_TEXT);
expect(Array.from(container.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(true);
expect((Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Clear') as HTMLButtonElement | undefined)?.disabled).toBe(true);
testState.mediaHostingRuntime = 'android';
await renderReplyModal('/i/thread/post-1', 'oekaki-posting.bso');
expect(container.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
});
it('opens the TeX preview modal from the /sci/ reply modal button', async () => {
testState.directoryByAddress['science-and-math.bso'] = {
address: 'science-and-math.bso',
directoryCode: 'sci',
features: {},
title: '/sci/ - Science & Math',
};
testState.communities['science-and-math.bso'] = { address: 'science-and-math.bso' };
await renderReplyModal('/sci/thread/post-1', 'science-and-math.bso');
const texButton = container.querySelector<HTMLButtonElement>('button[aria-label="preview_tex_equations"]');
expect(texButton?.textContent).toBe('TEX');
expect(texButton?.querySelector('sub')?.textContent).toBe('E');
await act(async () => {
texButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const texPreview = container.querySelector('dialog[aria-labelledby="tex-preview-title"]');
expect(texPreview).toBeTruthy();
expect(texPreview?.textContent).toContain('tex_preview_title');
});
it('clears TeX button focus after closing the TeX preview with Escape', async () => {
testState.directoryByAddress['science-and-math.bso'] = {
address: 'science-and-math.bso',
directoryCode: 'sci',
features: {},
title: '/sci/ - Science & Math',
};
testState.communities['science-and-math.bso'] = { address: 'science-and-math.bso' };
await renderReplyModal('/sci/thread/post-1', 'science-and-math.bso');
const texButton = container.querySelector<HTMLButtonElement>('button[aria-label="preview_tex_equations"]');
expect(texButton).toBeTruthy();
await act(async () => {
texButton?.focus();
texButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(document.activeElement).toBe(texButton);
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }));
});
expect(document.activeElement).not.toBe(texButton);
});
it('shows a flag selector on flag boards and publishes the default geographic request', async () => {
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
const flagSelect = container.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
expect(flagSelect).toBeTruthy();
expect(flagSelect?.value).toBe('country:auto');
expect(
Array.from(flagSelect?.options || [])
.slice(0, 3)
.map((option) => option.textContent),
).toEqual(['Geographic Location', 'Anarcho-Capitalist', 'Anarchist']);
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
expect(Boolean(linkInput!.compareDocumentPosition(flagSelect!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: '>>42\nselected text',
challengeRequest: {
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
},
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
});
});
it('shows the /pol/ flag selector for replies on a non-primary directory candidate board', async () => {
testState.directoryListsByCode.pol = {
directoryCode: 'pol',
features: { hasFlags: true },
title: '/pol/ - Politically Incorrect',
boards: [{ address: 'politically-incorrect.bso' }, { address: 'nothing-is-beyond-our-reach.bso' }],
};
await renderReplyModal('/pol/thread/post-1', 'nothing-is-beyond-our-reach.bso');
const flagSelect = container.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
expect(flagSelect).toBeTruthy();
expect(flagSelect?.value).toBe('country:auto');
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: '>>42\nselected text',
challengeRequest: {
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
},
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
});
});
it.each([
{ boardPath: '/bant/thread/post-1', communityAddress: 'international-nsfw.bso' },
{ boardPath: '/int/thread/post-1', communityAddress: 'international-sfw.bso' },
{ boardPath: '/sp/thread/post-1', communityAddress: 'sports-posting.bso' },
])('publishes geographic location on country-only boards without showing a flag selector', async ({ boardPath, communityAddress }) => {
await renderReplyModal(boardPath, communityAddress);
expect(container.querySelector<HTMLSelectElement>('select[aria-label="flag"]')).toBeNull();
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: '>>42\nselected text',
challengeRequest: {
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
},
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
});
});
it('publishes selected political flags from the reply modal', async () => {
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
const flagSelect = container.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
await dispatchInput(container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement, 'reply body');
await act(async () => {
if (flagSelect) {
flagSelect.value = 'pol:AC';
flagSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
});
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: 'reply body',
flairs: [{ type: 'pol', code: 'AC', text: 'flag:pol:AC' }],
});
});
it('uses the shared loading ellipsis while a reply upload is running', async () => {
testState.isUploading = true;
await renderReplyModal('/mu/thread/post-1');
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('uploading');
});
it('does not render an offline warning when the shared offline hook reports the board as online', async () => {
await renderReplyModal('/mu/thread/post-1');
expect(container.querySelector('[class*="offlineBoard"]')).toBeNull();
expect(container.textContent).not.toContain('community_offline_info');
});
it('prefers the resolved board entry when the modal prop address uses a different alias', async () => {
testState.offlineTitle = 'community_offline_info';
testState.offlineWarningVisible = true;
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.communities = {
'music-posting.eth': {
address: 'music-posting.eth',
},
};
testState.offlineStates = {
'music-posting.eth': {
isOffline: false,
isOnlineStatusLoading: false,
offlineTitle: '',
},
};
await renderReplyModal('/mu/thread/post-1', 'music-posting.bso');
expect(container.querySelector('[class*="offlineBoard"]')).toBeNull();
expect(container.textContent).not.toContain('community_offline_info');
});
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"]')[2];
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://temp.sh/example.png');
await clickButtonByText('post');
expect(container.textContent).toContain('error: expiring_media_link_alert:{"domain":"temp.sh"}');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
await dispatchInput(linkInput, 'https://example.com/file.png');
await clickButtonByText('post');
expect(container.textContent).toContain('file.png');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://example.com/file.png' });
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('publishes twimg query-format reply links with a path extension', async () => {
testState.openEmpty = true;
testState.selectedText = 'reply body';
await renderReplyModal('/mu/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
// The publish path normalizes the raw `?format=` link even without blurring the field.
await dispatchInput(linkInput, 'https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).toHaveBeenCalledWith(expect.objectContaining({ link: 'https://pbs.twimg.com/media/HKUAehoXUAAXkMb.jpg' }));
});
it('rewrites twimg query-format reply links to their path-extension form on blur', async () => {
testState.openEmpty = true;
testState.selectedText = 'reply body';
await renderReplyModal('/mu/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
await dispatchInput(linkInput, 'https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
await act(async () => {
linkInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
});
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HKUAehoXUAAXkMb.jpg');
});
it('moves YouTube links into reply content and publishes the thumbnail link on media-only boards', async () => {
const youtubeLink = 'https://youtu.be/reply123';
const thumbnailLink = 'https://img.youtube.com/vi/reply123/maxresdefault.jpg';
testState.openEmpty = true;
testState.selectedText = 'reply body';
testState.directoryByAddress['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLinkIsMedia: true },
title: '/mu/ - Music',
};
await renderReplyModal('/mu/thread/post-1');
const textarea = container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement;
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
const linkContainer = linkInput.parentElement as HTMLElement;
const getConversionNotice = () =>
Array.from(container.querySelectorAll<HTMLDivElement>('div'))
.reverse()
.find((element) => element.textContent?.includes('youtube_thumbnail_link_conversion_notice'));
await dispatchInput(linkInput, youtubeLink);
expect(linkInput.value).toBe(youtubeLink);
expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice:{"count":3}');
expect(linkContainer.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
expect(getConversionNotice()?.className).toContain('error');
await clickButtonByText('post');
expect(linkInput.value).toBe(thumbnailLink);
expect(textarea.value).toBe(`${youtubeLink}\nreply body`);
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: `${youtubeLink}\nreply body`,
link: thumbnailLink,
});
});
it('rejects unresolved YouTube links on media-only reply modal boards', async () => {
const youtubeLink = 'https://youtu.be/replymissing';
testState.fetchMock.mockResolvedValue({
headers: {
get: () => null,
},
ok: false,
});
testState.openEmpty = true;
testState.selectedText = 'reply body';
testState.directoryByAddress['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLinkIsMedia: true },
title: '/mu/ - Music',
};
await renderReplyModal('/mu/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
await dispatchInput(linkInput, youtubeLink);
await clickButtonByText('post');
await flushEffects(8);
expect(testState.fetchMock).toHaveBeenCalled();
expect(container.textContent).toContain('error: link_not_image_or_video_alert');
expect(linkInput.value).toBe(youtubeLink);
expect(testState.publishReplyMock).not.toHaveBeenCalled();
});
it('ignores duplicate reply clicks while youtube thumbnail resolution is pending', async () => {
const youtubeLink = 'https://youtu.be/replyslow';
const thumbnailLink = 'https://img.youtube.com/vi/replyslow/maxresdefault.jpg';
let resolveFetch: (response: unknown) => void = () => {};
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve;
});
testState.fetchMock.mockReturnValue(fetchPromise);
testState.openEmpty = true;
testState.selectedText = 'reply body';
testState.directoryByAddress['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLinkIsMedia: true },
title: '/mu/ - Music',
};
await renderReplyModal('/mu/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
const postButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'post') as HTMLButtonElement;
await dispatchInput(linkInput, youtubeLink);
await clickButtonByText('post');
expect(postButton.disabled).toBe(true);
await clickButtonByText('post');
expect(testState.fetchMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).not.toHaveBeenCalled();
await act(async () => {
resolveFetch({
headers: {
get: (name: string) => (name.toLowerCase() === 'content-length' ? '12345' : null),
},
ok: true,
});
await fetchPromise;
await Promise.resolve();
});
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: `${youtubeLink}\nreply body`,
link: thumbnailLink,
});
});
it('validates unsupported options and keeps fortune output out of preview state until reply publish', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'x y z');
expect(container.textContent).not.toContain('Unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('Unsupported options: x, y, z.');
const delayedOptionsError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'Unsupported options: x, y, z.');
expect(delayedOptionsError?.className).toContain('error');
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
await clickButtonByText('post');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
await dispatchInput(optionsInput, 'fortune');
expect(container.textContent).not.toContain('Unsupported options');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
content: 'reply body',
});
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: 'reply body[fortune color=#fd4d32]Excellent Luck[/fortune]',
});
randomSpy.mockRestore();
});
it('counts hidden fortune output in reply length validation without revealing it', async () => {
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement;
const longContent = 'x'.repeat(1930);
await dispatchInput(optionsInput, 'fortune');
await dispatchInput(textarea, longContent);
await waitForContentLengthValidation();
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: longContent });
expect(container.textContent).toContain('comment_field_too_long:2001');
expect(container.textContent).not.toContain('[fortune color=');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
});
it('links the unsupported sage option to its FAQ entry in reply modal', async () => {
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
await dispatchInput(optionsInput, 'sage');
await waitForOptionsValidation();
expect(container.textContent).toContain('Unsupported options: sage [learn why].');
expect(container.querySelector<HTMLAnchorElement>('a[href="/faq#sage"]')?.textContent).toBe('learn why');
});
it('supports fortune on the /s5s/ route when directory metadata is not loaded', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true;
testState.selectedText = '';
delete testState.directoryByAddress['silly-stuff.bso'];
await renderReplyModal('/s5s/thread/post-1', 'silly-stuff.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'fortune');
await waitForOptionsValidation();
expect(container.textContent).not.toContain('Unsupported options');
await dispatchInput(textarea as HTMLTextAreaElement, 'silly reply');
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: 'silly reply[fortune color=#fd4d32]Excellent Luck[/fortune]',
});
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'silly reply' });
randomSpy.mockRestore();
});
it('stores dice rolls in reply content on dice-enabled boards', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/tg/thread/post-1', 'traditional-games.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'dice+2d6');
await waitForOptionsValidation();
expect(container.textContent).not.toContain('Unsupported options');
await dispatchInput(textarea as HTMLTextAreaElement, 'dice reply');
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
content: '<b>Rolled 2, 2 = 4 (2d6)<br><br></b>dice reply',
});
randomSpy.mockRestore();
});
it('treats dice rolls as unsupported outside /tg/ and /qst/ in reply modal', async () => {
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'dice+1d6');
expect(container.textContent).not.toContain('Unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('Unsupported options: dice+1d6. Option "dice+1d6" is supported on: /qst/, /tg/.');
expect(container.querySelector<HTMLAnchorElement>('a[href="/qst"]')?.textContent).toBe('/qst/');
expect(container.querySelector<HTMLAnchorElement>('a[href="/tg"]')?.textContent).toBe('/tg/');
await dispatchInput(textarea as HTMLTextAreaElement, 'plain dice reply');
await clickButtonByText('post');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'plain dice reply' });
});
it('treats fortune as unsupported outside /b/ and /s5s/ in reply modal', async () => {
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/mu/thread/post-1', 'music-posting.eth');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'fortune');
expect(container.textContent).not.toContain('Unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('Unsupported options: fortune. Option "fortune" is supported on: /b/, /s5s/.');
expect(container.querySelector<HTMLAnchorElement>('a[href="/b"]')?.textContent).toBe('/b/');
expect(container.querySelector<HTMLAnchorElement>('a[href="/s5s"]')?.textContent).toBe('/s5s/');
await dispatchInput(textarea as HTMLTextAreaElement, 'plain reply');
await clickButtonByText('post');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'plain reply' });
});
it('shows BBCode controls only for board mods and inserts tags into the reply textarea', async () => {
testState.account = { author: { address: 'mod.eth', displayName: 'Alice' } };
testState.rolesByCommunity = {
'music-posting.eth': {
'mod.eth': { role: 'admin' },
},
};
await renderReplyModal('/mu/thread/post-1');
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
const redButton = container.querySelector<HTMLButtonElement>('button[aria-label="Red text"]');
const linkButton = container.querySelector<HTMLButtonElement>('button[aria-label="Link"]');
expect(textarea).toBeTruthy();
expect(redButton).toBeTruthy();
expect(linkButton).toBeTruthy();
expect(container.querySelector('select[aria-label="Text color"]')).toBeNull();
expect(container.textContent).not.toContain('mods only');
expect(container.textContent).not.toContain('Mod editor');
expect(container.textContent).toContain('warning: posting as admin');
const moderatorWarning = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'warning: posting as admin');
expect(moderatorWarning?.className).toContain('error');
expect(container.querySelector('button[aria-label="Quote"]')).toBeNull();
const selectionStart = textarea?.value.indexOf('selected text') ?? 0;
textarea?.setSelectionRange(selectionStart, selectionStart + 'selected text'.length);
await act(async () => {
redButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(textarea?.value).toBe('>>42\n[color=red]selected text[/color]');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: '>>42\n[color=red]selected text[/color]' });
testState.rolesByCommunity = {};
await rerenderReplyModal('/mu/thread/post-1');
expect(container.querySelector('button[aria-label="Red text"]')).toBeNull();
expect(container.textContent).not.toContain('mods only');
expect(container.textContent).not.toContain('warning: posting as admin');
});
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"]')[2];
await dispatchInput(nameInput, 'Alicia');
expect(testState.setAccountMock).toHaveBeenCalledWith({
author: { address: 'alice.eth', 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(container.textContent).toContain('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('redirects to the board index after a reply when nonoko is used', async () => {
testState.openEmpty = true;
testState.selectedText = '';
testState.publishReplyMock.mockImplementation(() => {
testState.replyIndex = 3;
});
await renderReplyModal('/mu/thread/post-1');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'nonoko');
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
await clickButtonByText('post');
await flushEffects();
await rerenderReplyModal('/mu/thread/post-1');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1);
expect(testState.closeModalMock).toHaveBeenCalledTimes(1);
expect(testState.navigateMock).toHaveBeenCalledWith('/mu');
});
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"]')[2];
expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
expect(container.textContent).not.toContain('warning');
expect(container.textContent).not.toContain('Spoiler?');
});
it('only shows pasted link filenames after media-only links are confirmed as files', async () => {
await renderReplyModal('/all/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
await dispatchInput(linkInput, 'https://imgur.com/8EJ2T76');
expect(container.textContent).toContain('no_file_chosen');
expect(container.textContent).not.toContain('8EJ2T76');
await dispatchInput(linkInput, 'https://imgur.com/8EJ2T76.jpg');
expect(container.textContent).toContain('8EJ2T76.jpg');
});
it('positions the draggable modal with left/top styles instead of a transform layer', async () => {
await renderReplyModal('/mu/thread/post-1');
const modal = container.querySelector<HTMLDivElement>('[class*="container"]');
expect(modal?.style.left).toBe('120px');
expect(modal?.style.top).toBe('80px');
expect(modal?.style.transform).toBe('');
expect(modal?.style.touchAction).toBe('none');
});
it('closes with Escape from the document on desktop', async () => {
await renderReplyModal('/mu/thread/post-1');
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
});
expect(testState.closeModalMock).toHaveBeenCalledTimes(1);
});
it('restores body selection styles if unmounted during a drag', async () => {
document.body.style.userSelect = 'text';
document.body.style.webkitUserSelect = 'auto';
await renderReplyModal('/mu/thread/post-1');
await act(async () => {
testState.dragHandler?.({
active: true,
event: { preventDefault: vi.fn() },
offset: [140, 100],
});
});
expect(document.body.style.userSelect).toBe('none');
expect(document.body.style.webkitUserSelect).toBe('none');
await act(async () => {
root.render(createElement(React.Fragment));
});
expect(document.body.style.userSelect).toBe('text');
expect(document.body.style.webkitUserSelect).toBe('auto');
});
it('initializes the drag spring once so typing rerenders do not recenter the modal', async () => {
await renderReplyModal('/mu/thread/post-1');
expect(testState.useSpringMock).toHaveBeenCalledWith(expect.any(Function), []);
const [configFactory, deps] = testState.useSpringMock.mock.calls[0] as [() => Record<string, unknown>, unknown[]];
expect(deps).toEqual([]);
expect(configFactory()).toEqual({
from: {
left: expect.any(Number),
top: expect.any(Number),
},
});
});
});