Files
Genarrative/src/components/creator/CreatorPages.test.tsx
T
suzmii 3b99da832f
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m1s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m48s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m8s
Project CI / Frontend tests (pull_request) Successful in 2m39s
Project CI / Backend tests (pull_request) Successful in 7m54s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m27s
Project CI / Repository checks (pull_request) Successful in 7m10s
Project CI / Native shell tests (pull_request) Successful in 9m41s
修复 CreatorPages 回关用例在并发负载下点击被吞导致的偶发失败
- 在「自己的粉丝支持回关和取消回关,移除须确认且只移除该行」用例中,先取到「回关」按钮并保留引用,不再取到后立即同步点击
- 新增等待该按钮 disabled 变为 false 再 fireEvent.click,避免关系 store 尚未 seed、按钮仍是 disabled 时 React 不派发 onClick 而把这次点击吞掉
- 补充简短中文注释,说明行首帧早于关系 store seed、必须等按钮可用再点
2026-10-06 15:03:25 +08:00

342 lines
12 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.
/* @vitest-environment jsdom */
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { CreatorConnection } from '../../../packages/shared/src/contracts/creator';
import { CreatorConnectionsPage } from './CreatorConnectionsPage';
import { CreatorPage } from './CreatorPage';
import { CreatorAuthor } from './CreatorRelationshipControl';
import { seedRelationship, setCreatorViewer } from './creatorRelationshipState';
const api = vi.hoisted(() => ({
viewer: 'a' as string | null,
list: vi.fn(),
read: vi.fn(),
write: vi.fn(),
profile: vi.fn(),
games: vi.fn(),
login: vi.fn(),
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => ({
user: api.viewer ? { id: api.viewer } : null,
canAccessProtectedData: !!api.viewer,
openLoginModal: api.login,
}),
}));
vi.mock('../../services/creatorProfileClient', async (original) => ({
...(await original<typeof import('../../services/creatorProfileClient')>()),
getCreatorConnections: api.list,
getCreatorRelationship: api.read,
changeCreatorRelationship: api.write,
getCreator: api.profile,
}));
vi.mock('../../services/gameDistributionClient', () => ({
listGames: api.games,
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: () => ({ resolvedUrl: '' }),
}));
const following = { isSelf: false, isFollowing: true, isFollowedBy: true };
const user = {
id: 'b',
displayName: '用户乙',
publicUserCode: '10002',
avatarUrl: null,
};
const row = (relation = following): CreatorConnection => ({
user,
followedAt: '2026-10-05T00:00:00Z',
relationship: relation,
});
const callbacks = {
onOpenCreator: vi.fn(),
onOpenConnections: vi.fn(),
onBack: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
api.viewer = 'a';
setCreatorViewer(null);
setCreatorViewer('a');
window.history.replaceState(
{},
'',
'/creators/connections?id=a&tab=following',
);
Object.defineProperty(Element.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
});
api.read.mockResolvedValue({ relationship: following });
api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 });
api.profile.mockResolvedValue({ user, followingCount: 1, followerCount: 2 });
api.games.mockResolvedValue([]);
});
afterEach(cleanup);
it.each(['following', 'followers'] as const)(
'他人的 %s 列表有主页链接,但缓存已有关系时仍无操作和补查',
async (tab) => {
seedRelationship('a', 'b', following);
api.list.mockResolvedValue({
items: [{ ...row(), relationship: null }],
nextCursor: null,
total: 1,
});
render(<CreatorConnectionsPage id="c" tab={tab} {...callbacks} />);
const link = await screen.findByRole('link', { name: /用户乙/ });
expect(link.getAttribute('href')).toBe('/creators?id=b');
fireEvent.click(link);
expect(callbacks.onOpenCreator).toHaveBeenCalledWith('b');
expect(screen.queryByRole('button', { name: '取消关注' })).toBeNull();
expect(screen.queryByRole('button', { name: '回关' })).toBeNull();
expect(screen.queryByRole('button', { name: '移除粉丝' })).toBeNull();
expect(api.read).not.toHaveBeenCalled();
},
);
it('本人关注取消后保留原行,重新关注,再取消并刷新后去行', async () => {
render(<CreatorConnectionsPage id="a" tab="following" {...callbacks} />);
await screen.findByRole('button', { name: '取消关注' });
api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 });
api.write.mockResolvedValue({
relationship: { ...following, isFollowing: false },
});
fireEvent.click(screen.getByRole('button', { name: '取消关注' }));
await screen.findByRole('button', { name: '重新关注' });
expect(screen.getByRole('link', { name: /用户乙/ })).toBeTruthy();
api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 });
api.write.mockResolvedValue({ relationship: following });
fireEvent.click(screen.getByRole('button', { name: '重新关注' }));
await screen.findByRole('button', { name: '取消关注' });
api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 });
api.write.mockResolvedValue({
relationship: { ...following, isFollowing: false },
});
fireEvent.click(screen.getByRole('button', { name: '取消关注' }));
await screen.findByRole('button', { name: '重新关注' });
await waitFor(() =>
expect(
(screen.getByRole('button', { name: '刷新列表' }) as HTMLButtonElement)
.disabled,
).toBe(false),
);
fireEvent.click(screen.getByRole('button', { name: '刷新列表' }));
await screen.findByText('暂无关注');
expect(screen.queryByRole('link', { name: /用户乙/ })).toBeNull();
});
it('自己的粉丝支持回关和取消回关,移除须确认且只移除该行', async () => {
api.list.mockResolvedValue({
items: [row({ ...following, isFollowing: false })],
nextCursor: null,
total: 1,
});
render(<CreatorConnectionsPage id="a" tab="followers" {...callbacks} />);
// 关系 store 的 seed 晚于行首帧,此时按钮仍是 disabled;React 不会对 disabled
// 元素派发 onClick,必须先等到按钮可用再点,否则这次点击会被直接吞掉。
const back = (await screen.findByRole('button', {
name: '回关',
})) as HTMLButtonElement;
await waitFor(() => expect(back.disabled).toBe(false));
api.write.mockResolvedValue({ relationship: following });
api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 });
fireEvent.click(back);
await screen.findByRole('button', { name: '取消关注' });
expect(api.write).toHaveBeenLastCalledWith('b', 'follow');
fireEvent.click(screen.getByRole('button', { name: '移除粉丝' }));
const dialog = await screen.findByRole('dialog');
expect(api.write).toHaveBeenCalledTimes(1);
api.write.mockResolvedValue({
relationship: { ...following, isFollowedBy: false },
});
api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 });
fireEvent.click(within(dialog).getByRole('button', { name: '移除粉丝' }));
await screen.findByText('暂无粉丝');
expect(api.write).toHaveBeenLastCalledWith('b', 'remove-follower');
});
it('列表切换账号,迟到响应不能把原账号的私有行带入', async () => {
let resolve!: (value: unknown) => void;
api.list.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
const view = render(
<CreatorConnectionsPage id="a" tab="following" {...callbacks} />,
);
api.viewer = 'c';
api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 });
view.rerender(
<CreatorConnectionsPage id="a" tab="following" {...callbacks} />,
);
await screen.findByText('暂无关注');
await act(async () => {
resolve({ items: [row()], nextCursor: null, total: 1 });
});
expect(screen.queryByRole('link', { name: /用户乙/ })).toBeNull();
});
it('主页查询作者游戏,自己不显示关注按钮,空游戏仍有主页', async () => {
render(
<CreatorPage
id={null}
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
await screen.findByText('还没有公开游戏');
expect(api.games).toHaveBeenCalledWith({ authorId: 'a' });
expect(screen.queryByRole('button', { name: '取消关注' })).toBeNull();
fireEvent.click(screen.getByRole('link', { name: '2 粉丝' }));
expect(callbacks.onOpenConnections).toHaveBeenCalledWith('a', 'followers');
});
it('游客点击关注仅唤起登录,自己作者也能打开主页', async () => {
api.viewer = null;
const view = render(
<CreatorAuthor user={user} onOpen={callbacks.onOpenCreator} />,
);
fireEvent.click(screen.getByRole('button', { name: '关注' }));
expect(api.login).toHaveBeenCalledTimes(1);
expect(api.write).not.toHaveBeenCalled();
api.viewer = 'b';
view.rerender(<CreatorAuthor user={user} onOpen={callbacks.onOpenCreator} />);
fireEvent.click(screen.getByRole('link', { name: '用户乙' }));
expect(callbacks.onOpenCreator).toHaveBeenCalledWith('b');
expect(screen.queryByRole('button', { name: '关注' })).toBeNull();
expect(api.write).not.toHaveBeenCalled();
});
it('分页加载追加,离开重进重新读取,临时取消的行不持久化', async () => {
api.list.mockImplementation((_id, _tab, cursor) =>
Promise.resolve(
cursor
? {
items: [
{ ...row(), user: { ...user, id: 'c', displayName: '用户丙' } },
],
nextCursor: null,
total: 2,
}
: { items: [row()], nextCursor: 'page2', total: 2 },
),
);
const view = render(
<CreatorConnectionsPage id="a" tab="following" {...callbacks} />,
);
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
await screen.findByRole('link', { name: /用户丙/ });
view.unmount();
api.list.mockClear();
render(<CreatorConnectionsPage id="a" tab="following" {...callbacks} />);
await screen.findByRole('link', { name: /用户丙/ });
expect(api.list).toHaveBeenCalledTimes(2);
});
it('显式主页登录后保持原作者,裸主页游客显示登录入口', async () => {
api.viewer = null;
const view = render(
<CreatorPage
id={null}
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
expect(screen.getByText('登录后查看自己的创作者主页。')).toBeTruthy();
view.rerender(
<CreatorPage
id="b"
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
await screen.findByText('还没有公开游戏');
fireEvent.click(screen.getByRole('button', { name: '关注' }));
api.viewer = 'a';
view.rerender(
<CreatorPage
id="b"
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
await screen.findByRole('button', { name: '取消关注' });
expect(api.profile).toHaveBeenLastCalledWith('b');
expect(api.write).not.toHaveBeenCalled();
});
it('同账号切换主页,迟到资料不覆盖新目标;关系读取失败不阻断公开内容', async () => {
let resolve!: (value: unknown) => void;
api.profile.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
api.read.mockRejectedValue(new Error('关系读取暂时失败'));
const view = render(
<CreatorPage
id="old"
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
api.profile.mockResolvedValue({
user: { ...user, id: 'new', displayName: '新的创作者' },
followingCount: 0,
followerCount: 0,
});
view.rerender(
<CreatorPage
id="new"
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
await screen.findByText('还没有公开游戏');
await screen.findByText('关系读取暂时失败');
await act(async () => {
resolve({
user: { ...user, displayName: '旧作者' },
followingCount: 1,
followerCount: 2,
});
});
expect(screen.getByText('新的创作者')).toBeTruthy();
expect(screen.queryByText('旧作者')).toBeNull();
});
it.each([null, 'a', 'b'])(
'主页 %s 顶部资料不自跳转,关系入口仍可用',
async (id) => {
const target = id ?? 'a';
api.profile.mockResolvedValue({
user: { ...user, id: target },
followingCount: 1,
followerCount: 2,
});
render(
<CreatorPage
id={id}
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
const name = await screen.findByText('用户乙');
expect(name.closest('a, button, [tabindex]')).toBeNull();
fireEvent.click(screen.getByRole('link', { name: '1 关注' }));
expect(callbacks.onOpenConnections).toHaveBeenCalledWith(
target,
'following',
);
},
);