51cb05f418
Project CI / AI game creator shell Rust crates (push) Successful in 5m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 7m37s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / Frontend tests (push) Successful in 4m40s
Project CI / AI game creator shell web tests (push) Successful in 3m50s
Project CI / Repository checks (push) Successful in 6m30s
Project CI / Native shell tests (push) Successful in 8m32s
本 PR 补齐创作者主页与关注粉丝功能的产品合同和工程设计,供后续按里程碑实施;本次只修改文档。 桌面导航第四项进入自己的创作者主页,“我的”移到第五项。游戏详情可进入作者主页并关注/取消关注;自己或他人的关注、粉丝列表均公开可查看,点击用户头像或昵称可进入其主页。本人支持取消/重新关注、回关和移除粉丝,并明确两个关注方向独立。 工程设计包含关系表与 DTO、认证和事务边界、跨页状态同步、共享组件、深链及验收要求,拆分关系查询和页面交互两个里程碑。按用户要求,游戏列表沿用最多 48 项的现有限制,不增加游戏目录分页改造。 验证: - 已合入最新 origin/master(21c79f54a);共享决策记录冲突保留双方新增内容。 - npm run check:encoding 通过。 - npm run check:doc-index 通过。 - git diff --check 与暂存差异检查通过。 - 相对 master 仅六份文档发生变化;没有业务代码或 schema 修改,未执行业务测试或部署。 Reviewed-on: #638 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
337 lines
12 KiB
TypeScript
337 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} />);
|
|
await screen.findByRole('button', { name: '回关' });
|
|
api.write.mockResolvedValue({ relationship: following });
|
|
api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 });
|
|
fireEvent.click(screen.getByRole('button', { name: '回关' }));
|
|
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',
|
|
);
|
|
},
|
|
);
|