test: expand markdown and post menu coverage

Adds focused regression coverage for quote previews, markdown link rendering, embed toggles, and desktop/mobile post-menu actions. It also fixes the undefined feed-state crash in use-state-string.ts and removes unnecessary runtime imports with import type.
This commit is contained in:
plebeius
2026-03-08 16:30:02 +08:00
parent 9444d3e01a
commit b2137c7f91
11 changed files with 1338 additions and 3 deletions
@@ -0,0 +1,235 @@
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 PostMenuMobile from '../post-menu-mobile';
(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(() => ({
copyShareLinkMock: vi.fn().mockResolvedValue(undefined),
copyToClipboardMock: vi.fn().mockResolvedValue(undefined),
hidden: false,
hideMock: vi.fn(),
mediaInfo: undefined as { thumbnail?: string; type?: string; url?: string } | undefined,
privileges: {
isAccountCommentAuthor: false,
isAccountMod: false,
},
unhideMock: vi.fn(),
validUrl: true,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({}));
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({
default: {
createInstance: () => ({
entries: vi.fn().mockResolvedValue([]),
getItem: vi.fn(),
removeItem: vi.fn(),
setItem: vi.fn(),
}),
},
}));
vi.mock('@floating-ui/react', () => ({
FloatingFocusManager: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children),
autoUpdate: () => undefined,
flip: () => ({}),
offset: () => ({}),
shift: () => ({}),
useClick: () => ({}),
useDismiss: () => ({}),
useFloating: () => ({
context: {},
floatingStyles: {},
refs: {
setFloating: () => undefined,
setReference: () => undefined,
},
}),
useId: () => 'mobile-menu',
useInteractions: () => ({
getFloatingProps: (props?: Record<string, unknown>) => props || {},
getReferenceProps: (props?: Record<string, unknown>) => props || {},
}),
useRole: () => ({}),
}));
vi.mock('../../../../lib/utils/media-utils', () => ({
getCommentMediaInfo: () => testState.mediaInfo,
}));
vi.mock('../../../../lib/utils/url-utils', () => ({
copyShareLinkToClipboard: testState.copyShareLinkMock,
isValidURL: () => testState.validUrl,
}));
vi.mock('../../../../lib/utils/clipboard-utils', () => ({
copyToClipboard: testState.copyToClipboardMock,
}));
vi.mock('../../../../lib/utils/route-utils', () => ({
getBoardPath: () => 'mu',
}));
vi.mock('../../../../hooks/use-directories', () => ({
useDirectories: () => [],
}));
vi.mock('../../../../hooks/use-author-privileges', () => ({
default: () => testState.privileges,
}));
vi.mock('../../../../hooks/use-hide', () => ({
default: () => ({
hidden: testState.hidden,
hide: testState.hideMock,
unhide: testState.unhideMock,
}),
}));
vi.mock('../../../../lib/utils/view-utils', () => ({
isBoardView: (pathname: string) => /^\/[^/]+$/.test(pathname),
isPostPageView: (pathname: string) => pathname.includes('/thread/'),
}));
vi.mock('../../../edit-menu/edit-menu', () => ({
default: ({ post }: { post?: { cid?: string } }) => createElement('div', { 'data-testid': 'edit-menu' }, post?.cid || 'missing'),
}));
let container: HTMLDivElement;
let root: Root;
const basePostMenu = {
authorAddress: '0xauthor',
cid: 'cid-1',
deleted: false,
link: undefined as string | undefined,
linkHeight: 0,
linkWidth: 0,
parentCid: undefined as string | undefined,
postCid: 'cid-1',
removed: false,
subplebbitAddress: 'music-posting.eth',
thumbnailUrl: undefined as string | undefined,
};
const renderMenu = async (initialEntry = '/mu', postMenu = basePostMenu) => {
await act(async () => {
root.render(
createElement(
MemoryRouter,
{ initialEntries: [initialEntry] },
createElement(PostMenuMobile, {
editMenuPost: { cid: postMenu.cid },
postMenu,
} as any),
),
);
});
};
const openMenu = async () => {
const trigger = document.body.querySelector('[title="Post menu"]');
await act(async () => {
trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
};
describe('PostMenuMobile', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.hidden = false;
testState.mediaInfo = undefined;
testState.privileges = {
isAccountCommentAuthor: false,
isAccountMod: false,
};
testState.validUrl = true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('opens the mobile menu, copies share metadata, and shows edit controls for privileged users', async () => {
testState.privileges = {
isAccountCommentAuthor: true,
isAccountMod: false,
};
await renderMenu('/mu');
expect(document.body.querySelector('[data-testid="edit-menu"]')?.textContent).toBe('cid-1');
await openMenu();
const copyLink = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_direct_link');
await act(async () => {
copyLink?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.copyShareLinkMock).toHaveBeenCalledWith('mu', 'thread', 'cid-1');
await openMenu();
const copyContentId = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_content_id');
await act(async () => {
copyContentId?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('cid-1');
await openMenu();
const hideThread = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread');
await act(async () => {
hideThread?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.hideMock).toHaveBeenCalled();
});
it('omits thread hiding on the root thread route and suppresses the menu for deleted posts', async () => {
await renderMenu('/mu/thread/cid-1');
await openMenu();
expect(Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread')).toBeUndefined();
await renderMenu('/mu', {
...basePostMenu,
deleted: true,
});
expect(document.body.querySelector('[title="Post menu"]')).toBeNull();
});
it('renders image search targets for image posts in the mobile menu', async () => {
testState.mediaInfo = {
type: 'image',
url: 'https://cdn.example/image.png',
};
await renderMenu('/mu', {
...basePostMenu,
link: 'https://cdn.example/image.png',
});
await openMenu();
const hrefs = Array.from(document.body.querySelectorAll('a')).map((link) => link.getAttribute('href'));
expect(hrefs).toEqual(
expect.arrayContaining([
'https://lens.google.com/uploadbyurl?url=https://cdn.example/image.png',
'https://www.yandex.com/images/search?img_url=https://cdn.example/image.png&rpt=imageview',
'https://saucenao.com/search.php?url=https://cdn.example/image.png',
]),
);
});
});
@@ -1,7 +1,7 @@
import { memo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Comment } from '@bitsocialhq/bitsocial-react-hooks';
import type { Comment } from '@bitsocialhq/bitsocial-react-hooks';
import { autoUpdate, flip, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
import styles from './post-menu-mobile.module.css';
import { getCommentMediaInfo } from '../../../lib/utils/media-utils';