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
- 在「自己的粉丝支持回关和取消回关,移除须确认且只移除该行」用例中,先取到「回关」按钮并保留引用,不再取到后立即同步点击 - 新增等待该按钮 disabled 变为 false 再 fireEvent.click,避免关系 store 尚未 seed、按钮仍是 disabled 时 React 不派发 onClick 而把这次点击吞掉 - 补充简短中文注释,说明行首帧早于关系 store seed、必须等按钮可用再点
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
/* @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',
|
||
);
|
||
},
|
||
);
|