Files
Genarrative/src/components/creator/CreatorPages.test.tsx
T
lhk229 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
补充创作者主页与关注粉丝工程文档 (#638)
本 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>
2026-10-05 22:05:11 +08:00

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',
);
},
);