2026-03-08 14:29:47 +08:00
|
|
|
|
import * as React from 'react';
|
|
|
|
|
|
import { createElement } from 'react';
|
|
|
|
|
|
import { createRoot, type Root } from 'react-dom/client';
|
2026-05-17 17:15:31 +07:00
|
|
|
|
import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
2026-03-08 14:29:47 +08:00
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
|
import PostForm, { LinkTypePreviewer } from '../post-form';
|
2026-05-30 15:06:47 +07:00
|
|
|
|
import { OEKAKI_WEB_WARNING_TEXT } from '../../../lib/oekaki/oekaki-copy';
|
2026-05-21 22:12:41 +07:00
|
|
|
|
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
(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: {
|
2026-05-19 15:45:22 +07:00
|
|
|
|
author: { address: 'alice.eth', displayName: 'Alice' },
|
2026-03-08 14:29:47 +08:00
|
|
|
|
subscriptions: ['music-posting.eth'],
|
|
|
|
|
|
},
|
2026-03-13 13:25:10 +08:00
|
|
|
|
accountComment: undefined as { communityAddress?: string } | undefined,
|
|
|
|
|
|
accountCommunityAddresses: ['mod.eth'] as string[],
|
2026-03-13 16:06:36 +08:00
|
|
|
|
comments: {} as Record<string, { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean }>,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
directories: [
|
|
|
|
|
|
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
|
|
|
|
|
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
|
2026-05-24 23:14:36 +07:00
|
|
|
|
] as Array<{ address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string }>,
|
2026-03-13 16:06:36 +08:00
|
|
|
|
editedComment: undefined as { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
gifFrameStatus: 'idle' as 'idle' | 'ready',
|
|
|
|
|
|
handleUploadMock: vi.fn(),
|
2026-05-30 15:06:47 +07:00
|
|
|
|
uploadFileMock: vi.fn(),
|
2026-03-08 14:29:47 +08:00
|
|
|
|
isOffline: false,
|
|
|
|
|
|
isOnlineStatusLoading: false,
|
2026-05-01 21:12:39 +07:00
|
|
|
|
isUploading: false,
|
2026-03-12 17:41:11 +08:00
|
|
|
|
isResolvingExternalQuotes: false,
|
2026-05-30 15:06:47 +07:00
|
|
|
|
mediaHostingRuntime: 'web' as 'web' | 'android' | 'electron',
|
2026-03-08 14:29:47 +08:00
|
|
|
|
navigateMock: vi.fn(),
|
|
|
|
|
|
offlineTitle: 'offline board',
|
|
|
|
|
|
postIndex: undefined as number | undefined,
|
2026-05-17 17:15:31 +07:00
|
|
|
|
publishedPostOptions: undefined as Record<string, unknown> | undefined,
|
|
|
|
|
|
publishPostOptions: {} as Record<string, unknown>,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
publishPostMock: vi.fn(),
|
|
|
|
|
|
publishReplyMock: vi.fn(),
|
2026-03-12 17:41:11 +08:00
|
|
|
|
publishReplyError: null as string | null,
|
|
|
|
|
|
publishReplyStateMessage: null as string | null,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
replyIndex: undefined as number | undefined,
|
|
|
|
|
|
resetPublishPostOptionsMock: vi.fn(),
|
|
|
|
|
|
resetPublishReplyOptionsMock: vi.fn(),
|
2026-03-13 13:25:10 +08:00
|
|
|
|
resolvedCommunityAddress: undefined as string | undefined,
|
2026-05-19 15:45:22 +07:00
|
|
|
|
rolesByCommunity: {} as Record<string, Record<string, { role?: string }>>,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
setAccountMock: vi.fn(),
|
|
|
|
|
|
setPublishPostOptionsMock: vi.fn(),
|
|
|
|
|
|
setPublishReplyOptionsMock: vi.fn(),
|
|
|
|
|
|
showUploadControls: true,
|
2026-03-13 13:25:10 +08:00
|
|
|
|
communities: {
|
2026-03-08 14:29:47 +08:00
|
|
|
|
'music-posting.eth': { address: 'music-posting.eth' },
|
|
|
|
|
|
} as Record<string, unknown>,
|
|
|
|
|
|
uploadComplete: undefined as ((uploadedUrl: string) => void) | undefined,
|
|
|
|
|
|
uploadMode: 'always',
|
|
|
|
|
|
uploadedFileName: 'picked.png' as string | null,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-05-21 18:55:36 +07:00
|
|
|
|
vi.mock('react-i18next', async () => {
|
|
|
|
|
|
const React = await vi.importActual<typeof import('react')>('react');
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
Trans: ({ i18nKey, components }: { components?: Record<string, React.ReactElement>; i18nKey: string }) => {
|
|
|
|
|
|
if (i18nKey === 'post_form_rules_faq_prompt') {
|
|
|
|
|
|
return React.createElement(
|
|
|
|
|
|
React.Fragment,
|
|
|
|
|
|
{},
|
|
|
|
|
|
'Please read the ',
|
|
|
|
|
|
components?.rules ? React.cloneElement(components.rules, {}, 'Rules') : 'Rules',
|
|
|
|
|
|
' and ',
|
|
|
|
|
|
components?.faq ? React.cloneElement(components.faq, {}, 'FAQ') : 'FAQ',
|
|
|
|
|
|
' before posting.',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-30 16:07:41 +07:00
|
|
|
|
if (i18nKey === 'post_form_flash_upload_prompt') {
|
|
|
|
|
|
return React.createElement(
|
|
|
|
|
|
React.Fragment,
|
|
|
|
|
|
{},
|
|
|
|
|
|
'Recommended SWF host: ',
|
|
|
|
|
|
components?.catbox ? React.cloneElement(components.catbox, {}, 'Catbox') : 'Catbox',
|
|
|
|
|
|
'. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link.',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-21 18:55:36 +07:00
|
|
|
|
|
|
|
|
|
|
return i18nKey;
|
|
|
|
|
|
},
|
|
|
|
|
|
useTranslation: () => ({
|
2026-05-30 16:07:41 +07:00
|
|
|
|
t: (key: string, options?: Record<string, unknown>) => {
|
|
|
|
|
|
if (key === 'choose_one') return 'Choose one:';
|
|
|
|
|
|
return options?.domain ? `${key}:${options.domain}` : key;
|
|
|
|
|
|
},
|
2026-05-21 18:55:36 +07:00
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
vi.mock('react-router-dom', async () => {
|
|
|
|
|
|
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
|
|
|
|
|
return {
|
|
|
|
|
|
...actual,
|
|
|
|
|
|
useNavigate: () => testState.navigateMock,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 22:11:36 +07:00
|
|
|
|
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
2026-03-08 14:29:47 +08:00
|
|
|
|
setAccount: (account: unknown) => testState.setAccountMock(account),
|
|
|
|
|
|
useAccount: () => testState.account,
|
|
|
|
|
|
useAccountComment: () => testState.accountComment,
|
2026-04-17 12:51:14 +07:00
|
|
|
|
useCommunity: (options?: { community?: { name?: string; publicKey?: string } }) => {
|
|
|
|
|
|
const communityKey = options?.community?.name ?? options?.community?.publicKey;
|
|
|
|
|
|
return communityKey ? testState.communities[communityKey] : undefined;
|
|
|
|
|
|
},
|
2026-03-08 14:29:47 +08:00
|
|
|
|
useEditedComment: () => ({ editedComment: testState.editedComment }),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-04-20 22:11:36 +07:00
|
|
|
|
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/communities', () => ({
|
2026-03-13 13:25:10 +08:00
|
|
|
|
default: (selector: (state: { communities: typeof testState.communities }) => unknown) => selector({ communities: testState.communities }),
|
2026-03-08 14:29:47 +08:00
|
|
|
|
}));
|
|
|
|
|
|
|
2026-04-20 22:11:36 +07:00
|
|
|
|
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages', () => ({
|
2026-03-08 14:29:47 +08:00
|
|
|
|
default: (selector: (state: { comments: typeof testState.comments }) => unknown) => selector({ comments: testState.comments }),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
|
vi.mock('../../../hooks/use-account-community-addresses', () => ({
|
|
|
|
|
|
useAccountCommunityAddresses: () => testState.accountCommunityAddresses,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('../../../hooks/use-directories', () => ({
|
2026-05-21 22:12:41 +07:00
|
|
|
|
findDirectoryByAddress: (directories: typeof testState.directories, address: string | undefined) => directories.find((entry) => entry.address === address),
|
2026-03-08 14:29:47 +08:00
|
|
|
|
useDirectories: () => testState.directories,
|
|
|
|
|
|
useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address),
|
2026-03-08 19:02:49 +08:00
|
|
|
|
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
2026-03-08 14:29:47 +08:00
|
|
|
|
}));
|
|
|
|
|
|
|
2026-04-17 12:51:14 +07:00
|
|
|
|
vi.mock('../../../hooks/use-community-identifiers', () => ({
|
|
|
|
|
|
useCommunityIdentifier: (address?: string) => (address ? { name: address } : undefined),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
|
vi.mock('../../../hooks/use-resolved-community-address', () => ({
|
|
|
|
|
|
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
}));
|
|
|
|
|
|
|
2026-05-19 15:45:22 +07:00
|
|
|
|
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),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-03-08 14:29:47 +08:00
|
|
|
|
vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({
|
|
|
|
|
|
default: () => ({
|
|
|
|
|
|
status: testState.gifFrameStatus,
|
|
|
|
|
|
}),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
|
vi.mock('../../../hooks/use-is-community-offline', () => ({
|
2026-03-08 14:29:47 +08:00
|
|
|
|
default: () => ({
|
|
|
|
|
|
isOffline: testState.isOffline,
|
|
|
|
|
|
isOnlineStatusLoading: testState.isOnlineStatusLoading,
|
|
|
|
|
|
offlineTitle: testState.offlineTitle,
|
|
|
|
|
|
}),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('../../../hooks/use-is-mobile', () => ({
|
|
|
|
|
|
default: () => false,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('../../../hooks/use-publish-post', async () => {
|
|
|
|
|
|
const React = await vi.importActual<typeof import('react')>('react');
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-13 13:25:10 +08:00
|
|
|
|
default: ({ communityAddress }: { communityAddress?: string }) => {
|
2026-05-17 17:15:31 +07:00
|
|
|
|
const [, forceUpdate] = React.useReducer((value: number) => value + 1, 0);
|
|
|
|
|
|
const getPublishPostOptions = React.useCallback(
|
|
|
|
|
|
() => ({
|
|
|
|
|
|
...(communityAddress ? { communityAddress } : {}),
|
|
|
|
|
|
...testState.publishPostOptions,
|
|
|
|
|
|
}),
|
|
|
|
|
|
[communityAddress],
|
|
|
|
|
|
);
|
2026-05-21 22:12:41 +07:00
|
|
|
|
const publishPost = React.useCallback(
|
|
|
|
|
|
(options?: Record<string, unknown>) => {
|
|
|
|
|
|
const sanitizedOptions = Object.entries(options || {}).reduce(
|
|
|
|
|
|
(acc, [key, value]) => {
|
|
|
|
|
|
acc[key] = value === '' ? undefined : value;
|
|
|
|
|
|
return acc;
|
|
|
|
|
|
},
|
|
|
|
|
|
{} as Record<string, unknown>,
|
|
|
|
|
|
);
|
|
|
|
|
|
testState.publishPostOptions = {
|
|
|
|
|
|
...getPublishPostOptions(),
|
|
|
|
|
|
...sanitizedOptions,
|
|
|
|
|
|
};
|
|
|
|
|
|
testState.publishedPostOptions = testState.publishPostOptions;
|
2026-05-23 16:54:38 +07:00
|
|
|
|
const result = testState.publishPostMock(options);
|
|
|
|
|
|
forceUpdate();
|
|
|
|
|
|
return result;
|
2026-05-21 22:12:41 +07:00
|
|
|
|
},
|
|
|
|
|
|
[getPublishPostOptions],
|
|
|
|
|
|
);
|
2026-05-17 17:15:31 +07:00
|
|
|
|
const resetPublishPostOptions = React.useCallback(() => {
|
|
|
|
|
|
testState.publishPostOptions = {};
|
|
|
|
|
|
testState.resetPublishPostOptionsMock();
|
|
|
|
|
|
forceUpdate();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
const setPublishPostOptions = React.useCallback(
|
|
|
|
|
|
(options: Record<string, unknown>) => {
|
|
|
|
|
|
const sanitizedOptions = Object.entries(options).reduce(
|
|
|
|
|
|
(acc, [key, value]) => {
|
|
|
|
|
|
acc[key] = value === '' ? undefined : value;
|
|
|
|
|
|
return acc;
|
|
|
|
|
|
},
|
|
|
|
|
|
{} as Record<string, unknown>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
testState.setPublishPostOptionsMock(options);
|
|
|
|
|
|
testState.publishPostOptions = {
|
|
|
|
|
|
...getPublishPostOptions(),
|
|
|
|
|
|
...sanitizedOptions,
|
|
|
|
|
|
};
|
|
|
|
|
|
forceUpdate();
|
|
|
|
|
|
},
|
|
|
|
|
|
[getPublishPostOptions],
|
|
|
|
|
|
);
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
postIndex: testState.postIndex,
|
2026-05-17 17:15:31 +07:00
|
|
|
|
publishPost,
|
|
|
|
|
|
publishPostOptions: getPublishPostOptions(),
|
|
|
|
|
|
resetPublishPostOptions,
|
|
|
|
|
|
setPublishPostOptions,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('../../../hooks/use-publish-reply', async () => {
|
|
|
|
|
|
const React = await vi.importActual<typeof import('react')>('react');
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-13 13:25:10 +08:00
|
|
|
|
default: ({ cid, postCid, communityAddress }: { cid: string; postCid?: string; communityAddress: string }) => {
|
2026-03-08 14:29:47 +08:00
|
|
|
|
const [publishReplyOptions, setPublishReplyOptionsState] = React.useState<Record<string, unknown>>({
|
|
|
|
|
|
parentCid: cid,
|
|
|
|
|
|
postCid: postCid ?? cid,
|
2026-03-13 13:25:10 +08:00
|
|
|
|
communityAddress,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
});
|
2026-05-21 22:12:41 +07:00
|
|
|
|
const publishReply = React.useCallback((options?: Record<string, unknown>) => {
|
|
|
|
|
|
if (options) {
|
|
|
|
|
|
testState.setPublishReplyOptionsMock(options);
|
|
|
|
|
|
setPublishReplyOptionsState((previous) => ({ ...previous, ...options }));
|
|
|
|
|
|
}
|
|
|
|
|
|
return testState.publishReplyMock(options);
|
|
|
|
|
|
}, []);
|
2026-05-17 17:15:31 +07:00
|
|
|
|
const resetPublishReplyOptions = React.useCallback(() => testState.resetPublishReplyOptionsMock(), []);
|
|
|
|
|
|
const setPublishReplyOptions = React.useCallback((options: Record<string, unknown>) => {
|
|
|
|
|
|
testState.setPublishReplyOptionsMock(options);
|
|
|
|
|
|
setPublishReplyOptionsState((previous) => ({ ...previous, ...options }));
|
|
|
|
|
|
}, []);
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-12 17:41:11 +08:00
|
|
|
|
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
2026-05-17 17:15:31 +07:00
|
|
|
|
publishReply,
|
2026-03-12 17:41:11 +08:00
|
|
|
|
publishReplyError: testState.publishReplyError,
|
|
|
|
|
|
publishReplyStateMessage: testState.publishReplyStateMessage,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
replyIndex: testState.replyIndex,
|
2026-05-17 17:15:31 +07:00
|
|
|
|
resetPublishReplyOptions,
|
|
|
|
|
|
setPublishReplyOptions,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
_publishReplyOptions: publishReplyOptions,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('../../../hooks/use-file-upload', () => ({
|
|
|
|
|
|
useFileUpload: ({ onUploadComplete }: { onUploadComplete: (uploadedUrl: string) => void }) => {
|
|
|
|
|
|
testState.uploadComplete = onUploadComplete;
|
|
|
|
|
|
return {
|
|
|
|
|
|
handleUpload: testState.handleUploadMock,
|
2026-05-30 15:06:47 +07:00
|
|
|
|
uploadFile: testState.uploadFileMock,
|
2026-05-01 21:12:39 +07:00
|
|
|
|
isUploading: testState.isUploading,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
uploadedFileName: testState.uploadedFileName,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-05-01 21:12:39 +07:00
|
|
|
|
vi.mock('../../loading-ellipsis', () => ({
|
|
|
|
|
|
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-03-08 14:29:47 +08:00
|
|
|
|
vi.mock('../../../lib/utils/media-utils', () => ({
|
2026-05-01 18:03:04 +07:00
|
|
|
|
getDisplayMediaInfoType: (type: string, t: (key: string) => string) => t(type),
|
2026-03-08 14:29:47 +08:00
|
|
|
|
getLinkMediaInfo: (link: string) => {
|
|
|
|
|
|
if (link.endsWith('.gif')) {
|
|
|
|
|
|
return { type: 'gif', url: link };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (link.endsWith('.png')) {
|
|
|
|
|
|
return { type: 'image', url: link };
|
|
|
|
|
|
}
|
2026-05-01 18:03:04 +07:00
|
|
|
|
if (link.endsWith('.mp4')) {
|
|
|
|
|
|
return { type: 'video', url: link };
|
|
|
|
|
|
}
|
2026-03-08 14:29:47 +08:00
|
|
|
|
return { type: 'link', url: link };
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
2026-05-30 15:06:47 +07:00
|
|
|
|
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
|
2026-03-08 14:29:47 +08:00
|
|
|
|
getShowUploadControls: () => testState.showUploadControls,
|
2026-05-30 15:06:47 +07:00
|
|
|
|
isWebRuntime: () => testState.mediaHostingRuntime === 'web',
|
2026-03-08 14:29:47 +08:00
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('../../../stores/use-media-hosting-store', () => ({
|
|
|
|
|
|
default: (selector: (state: { uploadMode: string }) => unknown) =>
|
|
|
|
|
|
selector({
|
|
|
|
|
|
uploadMode: testState.uploadMode,
|
|
|
|
|
|
}),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('lodash/debounce', () => ({
|
2026-05-21 22:12:41 +07:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-03-08 14:29:47 +08:00
|
|
|
|
return wrapped;
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
let container: HTMLDivElement;
|
|
|
|
|
|
let root: Root;
|
|
|
|
|
|
|
|
|
|
|
|
const flushEffects = async (count = 4) => {
|
|
|
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderPostForm = async (initialEntry: string) => {
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
root.render(
|
|
|
|
|
|
createElement(
|
|
|
|
|
|
MemoryRouter,
|
|
|
|
|
|
{ initialEntries: [initialEntry] },
|
|
|
|
|
|
createElement(
|
|
|
|
|
|
Routes,
|
|
|
|
|
|
{},
|
|
|
|
|
|
createElement(Route, { path: '/all/*', element: createElement(PostForm) }),
|
|
|
|
|
|
createElement(Route, { path: '/subs/*', element: createElement(PostForm) }),
|
|
|
|
|
|
createElement(Route, { path: '/mod/*', element: createElement(PostForm) }),
|
|
|
|
|
|
createElement(Route, { path: '/:boardIdentifier/thread/:commentCid/*', element: createElement(PostForm) }),
|
|
|
|
|
|
createElement(Route, { path: '/:boardIdentifier/*', element: createElement(PostForm) }),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
await flushEffects();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-17 17:15:31 +07:00
|
|
|
|
const KeyedPostForm = () => {
|
|
|
|
|
|
const location = useLocation();
|
|
|
|
|
|
return createElement(PostForm, { key: location.pathname });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderNavigablePostForm = async (initialEntry: string) => {
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
root.render(
|
|
|
|
|
|
createElement(
|
|
|
|
|
|
MemoryRouter,
|
|
|
|
|
|
{ initialEntries: [initialEntry] },
|
|
|
|
|
|
createElement(
|
|
|
|
|
|
React.Fragment,
|
|
|
|
|
|
{},
|
|
|
|
|
|
createElement(Link, { to: '/biz' }, 'go_biz'),
|
|
|
|
|
|
createElement(Routes, {}, createElement(Route, { path: '/:boardIdentifier/*', element: createElement(KeyedPostForm) })),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
await flushEffects();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-08 14:29:47 +08:00
|
|
|
|
const clickByText = async (scope: ParentNode, text: string, index = 0) => {
|
|
|
|
|
|
const button = Array.from(scope.querySelectorAll('button')).filter((candidate) => candidate.textContent === text)[index] as HTMLButtonElement | undefined;
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-17 17:15:31 +07:00
|
|
|
|
const clickLinkByText = async (scope: ParentNode, text: string, index = 0) => {
|
|
|
|
|
|
const link = Array.from(scope.querySelectorAll('a')).filter((candidate) => candidate.textContent === text)[index] as HTMLAnchorElement | undefined;
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
|
|
|
|
});
|
|
|
|
|
|
await flushEffects();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-08 14:29:47 +08:00
|
|
|
|
const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
|
|
|
|
|
await act(async () => {
|
2026-05-01 18:03:04 +07:00
|
|
|
|
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
|
|
|
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
|
|
|
|
|
descriptor?.set?.call(element, value);
|
2026-03-08 14:29:47 +08:00
|
|
|
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
|
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-21 22:12:41 +07:00
|
|
|
|
const waitForOptionsValidation = async () => {
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, POST_OPTIONS_VALIDATION_DELAY_MS + 20));
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-08 14:29:47 +08:00
|
|
|
|
const dispatchChange = async (element: HTMLInputElement | HTMLSelectElement, value: string | boolean) => {
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
if (typeof value === 'boolean' && 'checked' in element) {
|
|
|
|
|
|
element.checked = value;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
element.value = String(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
describe('PostForm', () => {
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
|
testState.account = {
|
2026-05-19 15:45:22 +07:00
|
|
|
|
author: { address: 'alice.eth', displayName: 'Alice' },
|
2026-03-08 14:29:47 +08:00
|
|
|
|
subscriptions: ['music-posting.eth'],
|
|
|
|
|
|
};
|
|
|
|
|
|
testState.accountComment = undefined;
|
2026-03-13 13:25:10 +08:00
|
|
|
|
testState.accountCommunityAddresses = ['mod.eth'];
|
2026-03-08 14:29:47 +08:00
|
|
|
|
testState.comments = {};
|
|
|
|
|
|
testState.directories = [
|
|
|
|
|
|
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
2026-05-24 23:14:36 +07:00
|
|
|
|
{ address: 'politically-incorrect.bso', directoryCode: 'pol', features: { hasFlags: true }, title: '/pol/ - Politically Incorrect' },
|
2026-06-01 10:57:13 +07:00
|
|
|
|
{ address: 'sports-posting.bso', directoryCode: 'sp', features: { hasFlags: true }, title: '/sp/ - Sports' },
|
2026-05-21 22:12:41 +07:00
|
|
|
|
{ address: 'random-nsfw.bso', features: {}, title: '/b/ - Random' },
|
2026-05-30 16:07:41 +07:00
|
|
|
|
{
|
|
|
|
|
|
address: 'flash-posting.bso',
|
|
|
|
|
|
directoryCode: 'f',
|
|
|
|
|
|
features: { postFlairs: true, requirePostFlairs: true, requirePostLink: true, requirePostLinkIsMedia: false },
|
|
|
|
|
|
title: '/f/ - Flash',
|
|
|
|
|
|
},
|
2026-05-21 22:12:41 +07:00
|
|
|
|
{ address: 'silly-stuff.bso', features: {}, title: '/s5s/ - Silly Stuff' },
|
|
|
|
|
|
{ address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' },
|
2026-03-08 14:29:47 +08:00
|
|
|
|
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
|
|
|
|
|
|
];
|
|
|
|
|
|
testState.editedComment = undefined;
|
|
|
|
|
|
testState.gifFrameStatus = 'idle';
|
|
|
|
|
|
testState.isOffline = false;
|
|
|
|
|
|
testState.isOnlineStatusLoading = false;
|
2026-05-01 21:12:39 +07:00
|
|
|
|
testState.isUploading = false;
|
2026-03-12 17:41:11 +08:00
|
|
|
|
testState.isResolvingExternalQuotes = false;
|
2026-05-30 15:06:47 +07:00
|
|
|
|
testState.mediaHostingRuntime = 'web';
|
2026-03-08 14:29:47 +08:00
|
|
|
|
testState.offlineTitle = 'offline board';
|
|
|
|
|
|
testState.postIndex = undefined;
|
2026-05-17 17:15:31 +07:00
|
|
|
|
testState.publishedPostOptions = undefined;
|
|
|
|
|
|
testState.publishPostOptions = {};
|
2026-03-12 17:41:11 +08:00
|
|
|
|
testState.publishReplyError = null;
|
|
|
|
|
|
testState.publishReplyStateMessage = null;
|
2026-03-08 14:29:47 +08:00
|
|
|
|
testState.replyIndex = undefined;
|
2026-03-13 13:25:10 +08:00
|
|
|
|
testState.resolvedCommunityAddress = undefined;
|
2026-05-19 15:45:22 +07:00
|
|
|
|
testState.rolesByCommunity = {};
|
2026-03-08 14:29:47 +08:00
|
|
|
|
testState.showUploadControls = true;
|
|
|
|
|
|
testState.uploadComplete = undefined;
|
|
|
|
|
|
testState.uploadMode = 'always';
|
|
|
|
|
|
testState.uploadedFileName = 'picked.png';
|
2026-03-13 13:25:10 +08:00
|
|
|
|
testState.communities = {
|
2026-03-08 14:29:47 +08:00
|
|
|
|
'music-posting.eth': { address: 'music-posting.eth' },
|
2026-05-21 22:12:41 +07:00
|
|
|
|
'traditional-games.bso': { address: 'traditional-games.bso' },
|
2026-03-08 14:29:47 +08:00
|
|
|
|
};
|
|
|
|
|
|
testState.handleUploadMock.mockReset();
|
2026-05-30 15:06:47 +07:00
|
|
|
|
testState.uploadFileMock.mockReset();
|
2026-03-08 14:29:47 +08:00
|
|
|
|
testState.navigateMock.mockReset();
|
|
|
|
|
|
testState.publishPostMock.mockReset();
|
|
|
|
|
|
testState.publishReplyMock.mockReset();
|
|
|
|
|
|
testState.resetPublishPostOptionsMock.mockReset();
|
|
|
|
|
|
testState.resetPublishReplyOptionsMock.mockReset();
|
|
|
|
|
|
testState.setAccountMock.mockReset();
|
|
|
|
|
|
testState.setPublishPostOptionsMock.mockReset();
|
|
|
|
|
|
testState.setPublishReplyOptionsMock.mockReset();
|
|
|
|
|
|
Object.defineProperty(globalThis, 'alert', {
|
|
|
|
|
|
configurable: true,
|
|
|
|
|
|
value: vi.fn(),
|
|
|
|
|
|
writable: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
container = document.createElement('div');
|
|
|
|
|
|
document.body.appendChild(container);
|
|
|
|
|
|
root = createRoot(container);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
act(() => root.unmount());
|
|
|
|
|
|
container.remove();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('shows the closed-thread notice when the current post can no longer receive replies', async () => {
|
|
|
|
|
|
testState.comments = {
|
|
|
|
|
|
'thread-cid': {
|
|
|
|
|
|
locked: true,
|
|
|
|
|
|
postCid: 'thread-cid',
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/mu/thread/thread-cid');
|
|
|
|
|
|
|
|
|
|
|
|
expect(container.textContent).toContain('thread_closed');
|
|
|
|
|
|
expect(container.textContent).toContain('may_not_reply');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-13 16:06:36 +08:00
|
|
|
|
it('shows the closed-thread notice for archived threads', async () => {
|
|
|
|
|
|
testState.comments = {
|
|
|
|
|
|
'thread-cid': {
|
|
|
|
|
|
postCid: 'thread-cid',
|
|
|
|
|
|
commentModeration: {
|
|
|
|
|
|
archived: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/mu/thread/thread-cid');
|
|
|
|
|
|
|
|
|
|
|
|
expect(container.textContent).toContain('thread_archived');
|
|
|
|
|
|
expect(container.textContent).toContain('may_not_reply');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-08 14:29:47 +08:00
|
|
|
|
it('opens the new-thread form, validates all-view requirements, and publishes a board post', async () => {
|
|
|
|
|
|
await renderPostForm('/all');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
expect(table).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'choose_file');
|
|
|
|
|
|
expect(testState.handleUploadMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
2026-05-16 16:30:32 +07:00
|
|
|
|
expect(globalThis.alert).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(container.textContent).toContain('error: empty_comment_alert');
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
|
|
|
|
|
const nameInput = textInputs[0];
|
2026-05-21 22:12:41 +07:00
|
|
|
|
const optionsInput = textInputs[1];
|
|
|
|
|
|
const subjectInput = textInputs[2];
|
|
|
|
|
|
const linkInput = textInputs[3];
|
2026-03-08 14:29:47 +08:00
|
|
|
|
const textarea = table?.querySelector('textarea');
|
|
|
|
|
|
const select = table?.querySelector('select');
|
|
|
|
|
|
|
|
|
|
|
|
expect(nameInput).toBeTruthy();
|
2026-05-21 22:12:41 +07:00
|
|
|
|
expect(optionsInput?.getAttribute('aria-label')).toBe('options');
|
2026-03-08 14:29:47 +08:00
|
|
|
|
expect(subjectInput).toBeTruthy();
|
|
|
|
|
|
expect(linkInput).toBeTruthy();
|
2026-05-01 18:03:04 +07:00
|
|
|
|
expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
|
2026-03-08 14:29:47 +08:00
|
|
|
|
expect(textarea).toBeTruthy();
|
|
|
|
|
|
expect(select).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(linkInput as HTMLInputElement, 'not-a-url');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
2026-05-16 16:30:32 +07:00
|
|
|
|
expect(globalThis.alert).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(container.textContent).toContain('error: invalid_url_alert');
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'A valid body');
|
2026-05-16 16:11:22 +07:00
|
|
|
|
await dispatchInput(linkInput as HTMLInputElement, 'https://i.4cdn.org/gif/file.jpg');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
expect(globalThis.alert).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(container.textContent).toContain('error: expiring_media_link_alert:i.4cdn.org');
|
|
|
|
|
|
expect(testState.publishPostMock).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(linkInput as HTMLInputElement, '');
|
2026-03-08 14:29:47 +08:00
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
2026-05-16 16:30:32 +07:00
|
|
|
|
expect(globalThis.alert).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(container.textContent).toContain('error: no_board_selected_warning');
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
await dispatchChange(select as HTMLSelectElement, 'music-posting.eth');
|
|
|
|
|
|
await dispatchInput(nameInput as HTMLInputElement, 'Alice Cooper');
|
|
|
|
|
|
await dispatchInput(subjectInput as HTMLInputElement, 'A thread');
|
|
|
|
|
|
|
|
|
|
|
|
const spoilerToggle = table?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
|
|
|
|
|
if (spoilerToggle) {
|
|
|
|
|
|
await dispatchChange(spoilerToggle, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
(globalThis.alert as ReturnType<typeof vi.fn>).mockClear();
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
2026-03-13 13:25:10 +08:00
|
|
|
|
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ communityAddress: 'music-posting.eth' });
|
2026-03-08 14:29:47 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-30 15:06:47 +07:00
|
|
|
|
it('shows Oekaki draw controls only on the /i/ board form', async () => {
|
|
|
|
|
|
testState.directories.push({
|
|
|
|
|
|
address: 'oekaki-posting.bso',
|
|
|
|
|
|
directoryCode: 'i',
|
|
|
|
|
|
features: { requirePostLink: true, requirePostLinkIsMedia: true },
|
|
|
|
|
|
title: '/i/ - Oekaki',
|
|
|
|
|
|
});
|
|
|
|
|
|
testState.communities['oekaki-posting.bso'] = { address: 'oekaki-posting.bso' };
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'oekaki-posting.bso';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/i');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table') as HTMLTableElement;
|
|
|
|
|
|
const drawRow = Array.from(table.querySelectorAll('tr')).find((row) => row.textContent?.includes('Size') && row.textContent?.includes('Replay'));
|
|
|
|
|
|
expect(table.textContent).toContain('Size');
|
|
|
|
|
|
expect(table.textContent).toContain('Replay');
|
|
|
|
|
|
expect(Array.from(table.querySelectorAll('span')).some((span) => span.textContent === '×')).toBe(true);
|
|
|
|
|
|
expect(drawRow?.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
|
|
|
|
|
|
expect(Array.from(table.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(true);
|
|
|
|
|
|
expect((Array.from(table.querySelectorAll('button')).find((button) => button.textContent === 'Clear') as HTMLButtonElement | undefined)?.disabled).toBe(true);
|
|
|
|
|
|
const rulesItems = Array.from(table.querySelectorAll('tr.rules li')).map((item) => item.textContent);
|
|
|
|
|
|
expect(rulesItems).toEqual(['Please read the Rules and FAQ before posting.', OEKAKI_WEB_WARNING_TEXT]);
|
|
|
|
|
|
|
|
|
|
|
|
testState.mediaHostingRuntime = 'electron';
|
|
|
|
|
|
await renderPostForm('/i');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
expect(container.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
|
|
|
|
|
|
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
|
|
|
|
|
await renderPostForm('/mu');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
expect(Array.from(container.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-17 17:15:31 +07:00
|
|
|
|
it('drops stale thread content when board navigation remounts the form before a link-only post', async () => {
|
|
|
|
|
|
await renderNavigablePostForm('/mu');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
let table = container.querySelector('table');
|
|
|
|
|
|
let textarea = table?.querySelector('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
expect(table).toBeTruthy();
|
|
|
|
|
|
expect(textarea).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'stale draft body');
|
|
|
|
|
|
expect(testState.publishPostOptions.content).toBe('stale draft body');
|
|
|
|
|
|
|
|
|
|
|
|
await clickLinkByText(container, 'go_biz');
|
|
|
|
|
|
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
table = container.querySelector('table');
|
|
|
|
|
|
textarea = table?.querySelector('textarea');
|
|
|
|
|
|
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
2026-05-21 22:12:41 +07:00
|
|
|
|
const linkInput = textInputs[3];
|
2026-05-17 17:15:31 +07:00
|
|
|
|
|
|
|
|
|
|
expect(textarea?.value).toBe('');
|
|
|
|
|
|
expect(linkInput).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(linkInput as HTMLInputElement, 'https://example.com/fresh.png');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(testState.publishedPostOptions?.link).toBe('https://example.com/fresh.png');
|
|
|
|
|
|
expect(testState.publishedPostOptions?.content).toBeUndefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-24 23:14:36 +07:00
|
|
|
|
it('shows a 4chan-style flag field on flag boards and publishes the default geographic request', async () => {
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'politically-incorrect.bso';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/pol');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const flagSelect = table?.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
expect(flagSelect).toBeTruthy();
|
|
|
|
|
|
expect(flagSelect?.value).toBe('country:auto');
|
|
|
|
|
|
expect(
|
|
|
|
|
|
Array.from(flagSelect?.options || [])
|
|
|
|
|
|
.slice(0, 4)
|
|
|
|
|
|
.map((option) => option.textContent),
|
|
|
|
|
|
).toEqual(['Geographic Location', 'Anarcho-Capitalist', 'Anarchist', 'Black Nationalist']);
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'flagged post');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledWith({
|
|
|
|
|
|
content: 'flagged post',
|
|
|
|
|
|
challengeRequest: {
|
|
|
|
|
|
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
|
|
|
|
|
|
},
|
|
|
|
|
|
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
|
2026-06-01 10:57:13 +07:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('publishes geographic location on /sp/ without showing a flag field', async () => {
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'sports-posting.bso';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/sp');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const flagSelect = table?.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
expect(flagSelect).toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'sports post');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledWith({
|
|
|
|
|
|
content: 'sports post',
|
|
|
|
|
|
challengeRequest: {
|
|
|
|
|
|
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
|
|
|
|
|
|
},
|
|
|
|
|
|
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
|
2026-05-24 23:14:36 +07:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('publishes selected political flags from the post form', async () => {
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'politically-incorrect.bso';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/pol');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const flagSelect = table?.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchChange(flagSelect as HTMLSelectElement, 'pol:AC');
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'memeflag post');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledWith({
|
|
|
|
|
|
content: 'memeflag post',
|
|
|
|
|
|
challengeRequest: {
|
|
|
|
|
|
challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'],
|
|
|
|
|
|
},
|
|
|
|
|
|
flairs: [{ type: 'pol', code: 'AC', text: 'flag:pol:AC' }],
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-30 16:07:41 +07:00
|
|
|
|
it('shows /f/ upload guidance and publishes the selected flash tag as a post flair', async () => {
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'flash-posting.bso';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/f');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const flashTagSelect = table?.querySelector<HTMLSelectElement>('select[name="flashTag"]');
|
|
|
|
|
|
const linkInput = Array.from(table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || []).find((input) => input.getAttribute('aria-label') === 'link');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
const catboxLink = table?.querySelector<HTMLAnchorElement>('a[href="https://catbox.moe/"]');
|
|
|
|
|
|
|
|
|
|
|
|
expect(flashTagSelect).toBeTruthy();
|
|
|
|
|
|
expect(flashTagSelect?.value).toBe('');
|
|
|
|
|
|
expect(Array.from(flashTagSelect?.options || []).map((option) => option.textContent)).toEqual([
|
|
|
|
|
|
'Choose one:',
|
|
|
|
|
|
'Hentai',
|
|
|
|
|
|
'Porn',
|
|
|
|
|
|
'Japanese',
|
|
|
|
|
|
'Anime',
|
|
|
|
|
|
'Game',
|
|
|
|
|
|
'Loop',
|
|
|
|
|
|
'Other',
|
|
|
|
|
|
]);
|
|
|
|
|
|
expect(catboxLink?.textContent).toBe('Catbox');
|
|
|
|
|
|
expect(container.textContent).toContain('Recommended SWF host: Catbox');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchChange(flashTagSelect as HTMLSelectElement, 'loop');
|
|
|
|
|
|
await dispatchInput(linkInput as HTMLInputElement, 'https://files.catbox.moe/movie.swf');
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'flash thread');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledWith({
|
|
|
|
|
|
content: 'flash thread',
|
|
|
|
|
|
flairs: [{ text: 'flash:loop' }],
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('does not publish a flash flair until a tag is selected', async () => {
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'flash-posting.bso';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/f');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const linkInput = Array.from(table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || []).find((input) => input.getAttribute('aria-label') === 'link');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(linkInput as HTMLInputElement, 'https://files.catbox.moe/movie.swf');
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'flash thread');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledWith({
|
|
|
|
|
|
content: 'flash thread',
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-21 22:12:41 +07:00
|
|
|
|
it('validates unsupported options and stores fortune output in post content', async () => {
|
|
|
|
|
|
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'random-nsfw.bso';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/b');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
expect(optionsInput).toBeTruthy();
|
|
|
|
|
|
expect(textarea).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(optionsInput as HTMLInputElement, 'x y z');
|
2026-05-24 19:17:50 +07:00
|
|
|
|
expect(container.textContent).not.toContain('Unsupported options');
|
2026-05-21 22:12:41 +07:00
|
|
|
|
|
|
|
|
|
|
await waitForOptionsValidation();
|
2026-05-24 19:17:50 +07:00
|
|
|
|
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.');
|
2026-05-21 22:12:41 +07:00
|
|
|
|
expect(delayedOptionsError?.className).toContain('error');
|
|
|
|
|
|
expect(delayedOptionsError?.className).toContain('formError');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'fortune body');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(optionsInput as HTMLInputElement, 'fortune');
|
|
|
|
|
|
|
2026-05-24 19:17:50 +07:00
|
|
|
|
expect(container.textContent).not.toContain('Unsupported options');
|
2026-05-21 22:12:41 +07:00
|
|
|
|
expect(testState.publishPostOptions.content).toBe('fortune body<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>');
|
|
|
|
|
|
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
randomSpy.mockRestore();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('supports fortune on the /s5s/ route when directory metadata is not loaded', async () => {
|
|
|
|
|
|
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'silly-stuff.bso';
|
|
|
|
|
|
testState.directories = testState.directories.filter((entry) => entry.address !== 'silly-stuff.bso');
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/s5s');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(optionsInput as HTMLInputElement, 'fortune');
|
|
|
|
|
|
await waitForOptionsValidation();
|
|
|
|
|
|
|
2026-05-24 19:17:50 +07:00
|
|
|
|
expect(container.textContent).not.toContain('Unsupported options');
|
2026-05-21 22:12:41 +07:00
|
|
|
|
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'silly fortune');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(testState.publishedPostOptions?.content).toBe('silly fortune<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>');
|
|
|
|
|
|
randomSpy.mockRestore();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('stores dice rolls in post content on dice-enabled boards', async () => {
|
|
|
|
|
|
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'quests.bso';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/qst');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(optionsInput as HTMLInputElement, 'dice+1d6+3');
|
|
|
|
|
|
await waitForOptionsValidation();
|
|
|
|
|
|
|
2026-05-24 19:17:50 +07:00
|
|
|
|
expect(container.textContent).not.toContain('Unsupported options');
|
2026-05-21 22:12:41 +07:00
|
|
|
|
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'dice body');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(testState.publishedPostOptions?.content).toBe('<b>Rolled 4 + 3 = 7 (1d6 + 3)<br><br></b>dice body');
|
|
|
|
|
|
randomSpy.mockRestore();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('treats fortune as unsupported outside /b/ and /s5s/', async () => {
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/mu');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(optionsInput as HTMLInputElement, 'fortune');
|
2026-05-24 19:17:50 +07:00
|
|
|
|
expect(container.textContent).not.toContain('Unsupported options');
|
2026-05-21 22:12:41 +07:00
|
|
|
|
|
|
|
|
|
|
await waitForOptionsValidation();
|
2026-05-24 19:17:50 +07:00
|
|
|
|
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/');
|
2026-05-21 22:12:41 +07:00
|
|
|
|
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'plain body');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(testState.publishPostOptions.content).toBe('plain body');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-24 19:17:50 +07:00
|
|
|
|
it('combines unavailable and unknown options in one clear post-form message', async () => {
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'politically-incorrect.bso';
|
|
|
|
|
|
testState.directories = [...testState.directories, { address: 'politically-incorrect.bso', features: {}, title: '/pol/ - Politically Incorrect' }];
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/pol');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(optionsInput as HTMLInputElement, 'sage fortune');
|
|
|
|
|
|
await waitForOptionsValidation();
|
|
|
|
|
|
|
|
|
|
|
|
expect(container.textContent).toContain('Unsupported options: sage, 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/');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-21 22:12:41 +07:00
|
|
|
|
it('treats dice rolls as unsupported outside /tg/ and /qst/', async () => {
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'random-nsfw.bso';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/b');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(optionsInput as HTMLInputElement, 'dice+1d6');
|
2026-05-24 19:17:50 +07:00
|
|
|
|
expect(container.textContent).not.toContain('Unsupported options');
|
2026-05-21 22:12:41 +07:00
|
|
|
|
|
|
|
|
|
|
await waitForOptionsValidation();
|
2026-05-24 19:17:50 +07:00
|
|
|
|
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/');
|
2026-05-21 22:12:41 +07:00
|
|
|
|
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'plain dice');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(testState.publishPostOptions.content).toBe('plain dice');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-19 15:45:22 +07:00
|
|
|
|
it('shows BBCode controls only for board mods and inserts tags into the post textarea', async () => {
|
|
|
|
|
|
testState.account = {
|
|
|
|
|
|
author: { address: 'mod.eth', displayName: 'Alice' },
|
|
|
|
|
|
subscriptions: ['music-posting.eth'],
|
|
|
|
|
|
};
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
|
|
|
|
|
testState.rolesByCommunity = {
|
|
|
|
|
|
'music-posting.eth': {
|
|
|
|
|
|
'mod.eth': { role: 'moderator' },
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/mu');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
const boldButton = table?.querySelector<HTMLButtonElement>('button[aria-label="Bold"]');
|
|
|
|
|
|
const redButton = table?.querySelector<HTMLButtonElement>('button[aria-label="Red text"]');
|
|
|
|
|
|
const linkButton = table?.querySelector<HTMLButtonElement>('button[aria-label="Link"]');
|
|
|
|
|
|
const sizeSelect = table?.querySelector<HTMLSelectElement>('select[aria-label="Text size"]');
|
|
|
|
|
|
const rows = Array.from(table?.querySelectorAll('tr') || []);
|
|
|
|
|
|
expect(textarea).toBeTruthy();
|
|
|
|
|
|
expect(boldButton).toBeTruthy();
|
|
|
|
|
|
expect(redButton).toBeTruthy();
|
|
|
|
|
|
expect(linkButton).toBeTruthy();
|
|
|
|
|
|
expect(sizeSelect).toBeTruthy();
|
|
|
|
|
|
expect(table?.querySelector('select[aria-label="Text color"]')).toBeNull();
|
2026-05-22 00:44:14 +07:00
|
|
|
|
expect(rows.some((row) => row.querySelector('td')?.textContent === 'format')).toBe(true);
|
2026-05-19 15:45:22 +07:00
|
|
|
|
expect(rows.some((row) => row.querySelector('td')?.textContent === 'comment')).toBe(true);
|
2026-05-22 00:44:14 +07:00
|
|
|
|
expect(container.textContent).toContain('warning: posting as moderator');
|
|
|
|
|
|
const moderatorWarning = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'warning: posting as moderator');
|
|
|
|
|
|
expect(moderatorWarning?.className).toContain('error');
|
|
|
|
|
|
expect(moderatorWarning?.className).toContain('formError');
|
2026-05-19 15:45:22 +07:00
|
|
|
|
expect(table?.textContent).not.toContain('Mod editor');
|
2026-05-22 00:44:14 +07:00
|
|
|
|
expect(table?.textContent).not.toContain('mods only');
|
2026-05-19 15:45:22 +07:00
|
|
|
|
expect(table?.querySelector('button[aria-label="Quote"]')).toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'hello world');
|
|
|
|
|
|
textarea?.setSelectionRange(0, 5);
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
boldButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(textarea?.value).toBe('[b]hello[/b] world');
|
|
|
|
|
|
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ content: '[b]hello[/b] world' });
|
|
|
|
|
|
|
|
|
|
|
|
const worldStart = textarea?.value.indexOf('world') ?? 0;
|
|
|
|
|
|
textarea?.setSelectionRange(worldStart, worldStart + 'world'.length);
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
redButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(textarea?.value).toBe('[b]hello[/b] [color=red]world[/color]');
|
|
|
|
|
|
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ content: '[b]hello[/b] [color=red]world[/color]' });
|
|
|
|
|
|
|
|
|
|
|
|
const helloStart = textarea?.value.indexOf('hello') ?? 0;
|
|
|
|
|
|
textarea?.setSelectionRange(helloStart, helloStart + 'hello'.length);
|
|
|
|
|
|
await dispatchChange(sizeSelect as HTMLSelectElement, '24');
|
|
|
|
|
|
|
|
|
|
|
|
expect(textarea?.value).toBe('[b][size=24]hello[/size][/b] [color=red]world[/color]');
|
|
|
|
|
|
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ content: '[b][size=24]hello[/size][/b] [color=red]world[/color]' });
|
|
|
|
|
|
|
|
|
|
|
|
const linkedWorldStart = textarea?.value.indexOf('world') ?? 0;
|
|
|
|
|
|
textarea?.setSelectionRange(linkedWorldStart, linkedWorldStart + 'world'.length);
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
linkButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const linkedContent = '[b][size=24]hello[/size][/b] [color=red][url=https://example.com]world[/url][/color]';
|
|
|
|
|
|
expect(textarea?.value).toBe(linkedContent);
|
|
|
|
|
|
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ content: linkedContent });
|
|
|
|
|
|
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'Preview');
|
|
|
|
|
|
const preview = table?.querySelector('[aria-label="BBCode preview"]');
|
|
|
|
|
|
expect(preview?.textContent).toContain('hello');
|
|
|
|
|
|
expect(preview?.textContent).toContain('world');
|
|
|
|
|
|
expect(preview?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/');
|
|
|
|
|
|
expect(table?.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe(linkedContent);
|
|
|
|
|
|
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'Edit');
|
|
|
|
|
|
expect(table?.querySelector('[aria-label="BBCode preview"]')).toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
testState.rolesByCommunity = {};
|
|
|
|
|
|
await renderPostForm('/mu');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
expect(container.querySelector('button[aria-label="Bold"]')).toBeNull();
|
|
|
|
|
|
expect(container.textContent).not.toContain('mods only');
|
2026-05-22 00:44:14 +07:00
|
|
|
|
expect(container.textContent).not.toContain('warning: posting as moderator');
|
2026-05-19 15:45:22 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-01 18:03:04 +07:00
|
|
|
|
it('shows the pasted file-link filename next to the upload button', async () => {
|
|
|
|
|
|
testState.uploadedFileName = null;
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/all');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
2026-05-21 22:12:41 +07:00
|
|
|
|
const linkInput = textInputs[3];
|
2026-05-01 18:03:04 +07:00
|
|
|
|
|
|
|
|
|
|
expect(table?.textContent).toContain('no_file_chosen');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(linkInput as HTMLInputElement, 'https://example.com/images/file%20name.jpg?size=large');
|
|
|
|
|
|
|
|
|
|
|
|
expect(table?.textContent).toContain('file name.jpg');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-22 00:03:52 +07:00
|
|
|
|
it('shows the rules and FAQ prompt at the bottom of the form with a board-specific rules link', async () => {
|
2026-05-21 18:55:36 +07:00
|
|
|
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/mu');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const rows = Array.from(table?.querySelectorAll('tr') || []);
|
|
|
|
|
|
const fileRowIndex = rows.findIndex((row) => row.querySelector('td')?.textContent === 'file');
|
|
|
|
|
|
const promptRowIndex = rows.findIndex((row) => row.textContent === 'Please read the Rules and FAQ before posting.');
|
|
|
|
|
|
const promptRow = rows[promptRowIndex];
|
|
|
|
|
|
const links = Array.from(promptRow?.querySelectorAll('a') || []);
|
|
|
|
|
|
|
|
|
|
|
|
expect(fileRowIndex).toBeGreaterThan(-1);
|
2026-05-22 00:03:52 +07:00
|
|
|
|
expect(promptRowIndex).toBeGreaterThan(fileRowIndex);
|
|
|
|
|
|
expect(promptRowIndex).toBe(rows.length - 1);
|
2026-05-21 18:55:36 +07:00
|
|
|
|
expect(promptRow?.className).toBe('rules');
|
|
|
|
|
|
expect(promptRow?.querySelector('ul')?.className).toBe('rules');
|
|
|
|
|
|
expect(links.map((link) => link.textContent)).toEqual(['Rules', 'FAQ']);
|
|
|
|
|
|
expect(links.map((link) => link.getAttribute('href'))).toEqual(['/rules/mu', '/faq']);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-21 15:34:21 +07:00
|
|
|
|
it('shortens long pasted file-link filenames next to the upload button', async () => {
|
|
|
|
|
|
testState.uploadedFileName = null;
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/all');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
2026-05-21 22:12:41 +07:00
|
|
|
|
const linkInput = textInputs[3];
|
2026-05-21 15:34:21 +07:00
|
|
|
|
const longFilename = 'TELEMMGLPICT000378070158_17159651831200_trans_NvBQzQNjv4BqpVlberWd9EgFPZtcLiMQf0Rf_Wk3V23H2268P_XkPxc.jpeg';
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(linkInput as HTMLInputElement, `https://www.telegraph.co.uk/multimedia/${longFilename}`);
|
|
|
|
|
|
|
|
|
|
|
|
expect(table?.textContent).toContain('TELEMMGLPICT...P_XkPxc.jpeg');
|
|
|
|
|
|
expect(table?.textContent).not.toContain(longFilename);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-01 21:12:39 +07:00
|
|
|
|
it('uses the shared loading ellipsis while a post form upload is running', async () => {
|
|
|
|
|
|
testState.isUploading = true;
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/all');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('uploading');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-08 14:29:47 +08:00
|
|
|
|
it('redirects to the pending route when a post publish index is already available on mount', async () => {
|
|
|
|
|
|
testState.postIndex = 7;
|
2026-03-13 13:25:10 +08:00
|
|
|
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/mu');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
await flushEffects();
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalledTimes(1);
|
2026-05-23 17:24:22 +07:00
|
|
|
|
expect(testState.navigateMock).toHaveBeenCalledWith('/pending/7', { state: { boardPath: 'mu' } });
|
2026-03-08 14:29:47 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-23 16:54:38 +07:00
|
|
|
|
it('redirects new posts to the board index when nonoko is used', async () => {
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
|
|
|
|
|
testState.publishPostMock.mockImplementation(() => {
|
|
|
|
|
|
testState.postIndex = 7;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/mu');
|
|
|
|
|
|
await clickByText(container, 'start_new_thread');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
|
|
|
|
|
|
const subjectInput = table?.querySelector<HTMLInputElement>('input[aria-label="subject"]');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(optionsInput as HTMLInputElement, 'nonoko');
|
|
|
|
|
|
await dispatchInput(subjectInput as HTMLInputElement, 'Thread title');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
await flushEffects();
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(testState.navigateMock).toHaveBeenCalledWith('/mu', { state: { nonokoPendingAccountCommentIndex: 7 } });
|
|
|
|
|
|
expect(testState.navigateMock).not.toHaveBeenCalledWith('/pending/7');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-08 14:29:47 +08:00
|
|
|
|
it('resets the reply form after a completed reply publish', async () => {
|
|
|
|
|
|
testState.comments = {
|
|
|
|
|
|
'thread-cid': {
|
|
|
|
|
|
postCid: 'thread-cid',
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
testState.replyIndex = 4;
|
2026-03-13 13:25:10 +08:00
|
|
|
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/mu/thread/thread-cid');
|
|
|
|
|
|
await clickByText(container, 'post_a_reply');
|
|
|
|
|
|
await flushEffects();
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(container.querySelector('table')).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-23 16:54:38 +07:00
|
|
|
|
it('redirects replies from the inline form to the board index when nonoko is used', async () => {
|
|
|
|
|
|
testState.comments = {
|
|
|
|
|
|
'thread-cid': {
|
|
|
|
|
|
postCid: 'thread-cid',
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
|
|
|
|
|
testState.publishReplyMock.mockImplementation(() => {
|
|
|
|
|
|
testState.replyIndex = 4;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/mu/thread/thread-cid');
|
|
|
|
|
|
await clickByText(container, 'post_a_reply');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
|
|
|
|
|
|
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(optionsInput as HTMLInputElement, 'nonoko');
|
|
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'Reply body');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
await flushEffects();
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(testState.navigateMock).toHaveBeenCalledWith('/mu');
|
|
|
|
|
|
expect(container.querySelector('table')).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-08 14:29:47 +08:00
|
|
|
|
it('publishes replies from the open reply form', async () => {
|
|
|
|
|
|
testState.comments = {
|
|
|
|
|
|
'thread-cid': {
|
|
|
|
|
|
postCid: 'thread-cid',
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
testState.isOffline = true;
|
2026-03-13 13:25:10 +08:00
|
|
|
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
await renderPostForm('/mu/thread/thread-cid');
|
|
|
|
|
|
await clickByText(container, 'post_a_reply');
|
|
|
|
|
|
|
|
|
|
|
|
const table = container.querySelector('table');
|
|
|
|
|
|
const textarea = table?.querySelector('textarea');
|
|
|
|
|
|
expect(table).toBeTruthy();
|
|
|
|
|
|
expect(textarea).toBeTruthy();
|
|
|
|
|
|
expect(container.textContent).toContain('offline board');
|
|
|
|
|
|
|
2026-05-16 16:30:32 +07:00
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
expect(globalThis.alert).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(container.textContent).toContain('error: empty_comment_alert');
|
|
|
|
|
|
|
|
|
|
|
|
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
2026-05-21 22:12:41 +07:00
|
|
|
|
const linkInput = textInputs[2];
|
2026-05-16 16:30:32 +07:00
|
|
|
|
expect(linkInput).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(linkInput as HTMLInputElement, 'not-a-url');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
expect(globalThis.alert).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(container.textContent).toContain('error: invalid_url_alert');
|
|
|
|
|
|
|
|
|
|
|
|
await dispatchInput(linkInput as HTMLInputElement, '');
|
2026-03-08 14:29:47 +08:00
|
|
|
|
await dispatchInput(textarea as HTMLTextAreaElement, 'Reply body');
|
|
|
|
|
|
await clickByText(table as HTMLTableElement, 'post');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('LinkTypePreviewer', () => {
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
container = document.createElement('div');
|
|
|
|
|
|
document.body.appendChild(container);
|
|
|
|
|
|
root = createRoot(container);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
act(() => root.unmount());
|
|
|
|
|
|
container.remove();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('describes gif previews and invalid links for the post form link helper', async () => {
|
|
|
|
|
|
testState.gifFrameStatus = 'ready';
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
root.render(createElement(LinkTypePreviewer, { link: 'https://example.com/file.gif' }));
|
|
|
|
|
|
});
|
2026-05-01 18:03:04 +07:00
|
|
|
|
expect(container.textContent).toBe('type: animated_gif');
|
|
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
root.render(createElement(LinkTypePreviewer, { link: 'https://example.com/file.mp4' }));
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(container.textContent).toBe('type: video');
|
2026-03-08 14:29:47 +08:00
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
root.render(createElement(LinkTypePreviewer, { link: 'not-a-url' }));
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(container.textContent).toBe('invalid_url');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|