Files
Genarrative/apps/admin-web/src/pages/AdminGameReviewsPage.test.tsx
T
lhk229 b60aea4e71
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m23s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Backend tests (push) Successful in 6m19s
Project CI / Native shell tests (push) Successful in 8m7s
实现网站游戏评分与评价 (#561)
公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 1–10 分评价,评论可空且最多 4000 个 Unicode 码点;游客可分页浏览评价并查看平均分与评分人数。

- 新增私有评价表及共享 DTO/API,事务内校验游戏公开可见性、保证唯一性,并计算分页与全量评分统计;下架或封禁后关闭读写,重新公开及更新版本保留评价。
- 详情页支持编辑预填、取消、提交期间冻结输入、失败保留草稿,以及保存、翻页和账号/游戏切换时的旧响应隔离;复用现有认证与公共组件。
- 同步主规范、里程碑、实施计划、生成绑定、表目录和验收脚本。不扩展 AGC、外部 API、评论审核或评分缓存。

验证:
- 本轮审查复跑前端定向测试 40 项、评价领域测试 2 项,均通过。
- 网站 typecheck、Rust/TypeScript DTO 对齐、SpacetimeDB schema、文档索引、编码与 diff 检查通过。
- 实施文档另记录本地隔离数据库/API 49 项及桌面/移动视口浏览器 smoke 通过;本轮审查未重跑这些运行时验收,也未运行完整测试套件。

发布说明:新增 SpacetimeDB 表,部署时需同步 module 与 API。尚未部署 dev/production,待用户验收。

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/561
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-10-01 19:19:58 +08:00

442 lines
14 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, test, vi } from 'vitest';
import {
getAdminGameReview,
listAdminGameReviewGames,
listAdminGameReviews,
moderateAdminGameReview,
} from '../api/adminApiClient';
import type {
AdminGameReview,
AdminGameReviewDetailResponse,
AdminGameReviewsResponse,
} from '../api/adminApiTypes';
import { AdminGameReviewsPage } from './AdminGameReviewsPage';
vi.mock('../api/adminApiClient', () => ({
listAdminGameReviews: vi.fn(),
listAdminGameReviewGames: vi.fn(),
getAdminGameReview: vi.fn(),
moderateAdminGameReview: vi.fn(),
isAdminApiError: (error: unknown) =>
typeof error === 'object' && error !== null && 'status' in error,
formatAdminApiError: (error: unknown) =>
error instanceof Error ? error.message : '请求失败',
}));
const review: AdminGameReview = {
id: '6:game_1user_1',
gameId: 'game_1',
game: { title: '测试游戏', status: 'published' },
author: { id: 'user_1', name: '用户甲' },
score: 8,
comment: '<b>中文😀</b>\n完整正文',
isHidden: false,
createdAt: '2026-10-01T01:00:00Z',
updatedAt: '2026-10-01T01:00:00Z',
};
function list(
overrides: Partial<AdminGameReviewsResponse> = {},
): AdminGameReviewsResponse {
return {
reviews: [review],
page: 1,
pageSize: 20,
total: 1,
totalPages: 1,
...overrides,
};
}
const unauthorized = vi.fn();
beforeEach(() => {
vi.resetAllMocks();
window.history.replaceState(null, '', '#game-reviews');
vi.mocked(listAdminGameReviews).mockResolvedValue(list());
vi.mocked(listAdminGameReviewGames).mockResolvedValue({
games: [{ gameId: 'game_2', title: '第二个游戏', status: 'suspended' }],
page: 1,
pageSize: 20,
total: 1,
totalPages: 1,
});
vi.mocked(getAdminGameReview).mockResolvedValue({ review, operations: [] });
vi.mocked(moderateAdminGameReview).mockResolvedValue({
review: { ...review, isHidden: true },
operation: {
id: 'op',
reviewId: review.id,
gameId: review.gameId,
userId: review.author.id,
reviewCreatedAt: review.createdAt,
action: 'hide',
adminUserId: 'admin',
reason: '广告',
createdAt: review.createdAt,
},
replayed: false,
});
});
afterEach(() => {
cleanup();
window.history.replaceState(null, '', '/');
});
function show() {
return render(
<AdminGameReviewsPage token="token" onUnauthorized={unauthorized} />,
);
}
async function row() {
return (await screen.findByText('用户甲')).closest('tr')!;
}
test('入口游戏参数和hash变化生效,组合筛选分页在服务端查询,重置回第一页', async () => {
window.history.replaceState(null, '', '#game-reviews?gameId=game_1');
vi.mocked(listAdminGameReviews).mockResolvedValue(
list({ total: 21, totalPages: 2 }),
);
show();
await row();
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
'token',
expect.objectContaining({ gameId: 'game_1', page: 1 }),
);
fireEvent.change(screen.getByLabelText('评价用户 ID'), {
target: { value: 'user_1' },
});
fireEvent.change(screen.getByLabelText('评论关键词'), {
target: { value: '中文' },
});
fireEvent.change(screen.getByLabelText('评价状态'), {
target: { value: 'hidden' },
});
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() =>
expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', {
gameId: 'game_1',
userId: 'user_1',
keyword: '中文',
status: 'hidden',
page: 1,
pageSize: 20,
}),
);
await row();
fireEvent.click(
within(screen.getByRole('navigation', { name: '评价分页' })).getByRole(
'button',
{ name: '下一页' },
),
);
await waitFor(() =>
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
'token',
expect.objectContaining({ page: 2, keyword: '中文' }),
),
);
fireEvent.click(screen.getByRole('button', { name: '重置' }));
await waitFor(() =>
expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', {
gameId: '',
userId: '',
keyword: '',
status: 'all',
page: 1,
pageSize: 20,
}),
);
act(() => {
window.history.replaceState(null, '', '#game-reviews?gameId=game_2');
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
await waitFor(() =>
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
'token',
expect.objectContaining({ gameId: 'game_2', page: 1 }),
),
);
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
'game_2',
);
});
test('名称查找包含下架游戏,选择后按游戏ID筛选', async () => {
show();
await row();
fireEvent.click(screen.getByRole('button', { name: '按名称查找游戏' }));
const dialog = screen.getByRole('dialog', { name: '查找游戏' });
await within(dialog).findByText('第二个游戏');
fireEvent.change(within(dialog).getByLabelText('游戏名称或 ID'), {
target: { value: '第二个' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '搜索' }));
await waitFor(() =>
expect(listAdminGameReviewGames).toHaveBeenLastCalledWith('token', {
query: '第二个',
page: 1,
pageSize: 20,
}),
);
await within(dialog).findByText('第二个游戏');
fireEvent.click(within(dialog).getByRole('button', { name: '选择' }));
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
'game_2',
);
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() =>
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
'token',
expect.objectContaining({ gameId: 'game_2' }),
),
);
});
test('隐藏原因必填,失败保留草稿,同次重试复用幂等key,取消不写入', async () => {
vi.mocked(moderateAdminGameReview).mockRejectedValueOnce(
new Error('网络失败'),
);
show();
fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' }));
let dialog = screen.getByRole('dialog', { name: '隐藏评价' });
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
expect(
await within(dialog).findByText('原因必须为 1–4000 个字符'),
).toBeTruthy();
expect(moderateAdminGameReview).not.toHaveBeenCalled();
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
target: { value: ' 广告😀 ' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
await within(dialog).findByText('网络失败');
expect(
(within(dialog).getByLabelText('操作原因(必填)') as HTMLTextAreaElement)
.value,
).toBe(' 广告😀 ');
const first = vi.mocked(moderateAdminGameReview).mock.calls[0]!;
expect(first[3]).toEqual({
action: 'hide',
expectedCreatedAt: review.createdAt,
reason: '广告😀',
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
await screen.findByText('评价已隐藏');
expect(vi.mocked(moderateAdminGameReview).mock.calls[1]![2]).toBe(first[2]);
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
dialog = screen.getByRole('dialog', { name: '删除评价' });
expect(
within(dialog).getByText('删除后不可恢复,用户可以重新评价'),
).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(moderateAdminGameReview).toHaveBeenCalledTimes(2);
});
test('4001码点原因不能提交,恢复复用确认且不要求原因', async () => {
vi.mocked(listAdminGameReviews).mockResolvedValue(
list({ reviews: [{ ...review, isHidden: true }] }),
);
show();
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
const deletion = screen.getByRole('dialog', { name: '删除评价' });
fireEvent.change(within(deletion).getByLabelText('操作原因(必填)'), {
target: { value: '😀'.repeat(4001) },
});
expect(within(deletion).getByText('4001/4000')).toBeTruthy();
expect(
(
within(deletion).getByRole('button', {
name: '确认删除',
}) as HTMLButtonElement
).disabled,
).toBe(true);
fireEvent.click(within(deletion).getByRole('button', { name: '取消' }));
fireEvent.click(within(await row()).getByRole('button', { name: '恢复' }));
const confirmation = screen.getByRole('dialog', { name: '确认操作' });
fireEvent.click(within(confirmation).getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(moderateAdminGameReview).toHaveBeenCalledWith(
'token',
review.id,
expect.any(String),
{ action: 'restore', expectedCreatedAt: review.createdAt },
),
);
});
test('删除使当前页越界时回到最后一页并保留条件', async () => {
vi.mocked(listAdminGameReviews).mockImplementation(async (_token, query) =>
list({ page: query?.page ?? 1, total: 21, totalPages: 2 }),
);
show();
await row();
fireEvent.click(
within(screen.getByRole('navigation', { name: '评价分页' })).getByRole(
'button',
{ name: '下一页' },
),
);
await waitFor(() =>
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
'token',
expect.objectContaining({ page: 2 }),
),
);
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
const dialog = screen.getByRole('dialog', { name: '删除评价' });
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
target: { value: '重复内容' },
});
vi.mocked(listAdminGameReviews).mockResolvedValue(
list({ total: 20, totalPages: 1 }),
);
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除' }));
await waitFor(() =>
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
'token',
expect.objectContaining({ page: 1 }),
),
);
expect(screen.getByText('评价已删除')).toBeTruthy();
});
test('详情完整纯文本只读并展示后台操作原因', async () => {
vi.mocked(getAdminGameReview).mockResolvedValue({
review,
operations: [
{
id: 'op',
reviewId: review.id,
gameId: review.gameId,
userId: review.author.id,
reviewCreatedAt: review.createdAt,
action: 'hide',
adminUserId: 'admin_1',
reason: '广告内容',
createdAt: review.createdAt,
},
],
});
show();
fireEvent.click(
within(await row()).getByRole('button', { name: '查看详情' }),
);
const dialog = screen.getByRole('dialog', { name: '评价详情' });
expect(await within(dialog).findByText('广告内容')).toBeTruthy();
expect(dialog.textContent).toContain(review.comment);
expect(dialog.querySelector('b')).toBeNull();
expect(within(dialog).getByText('admin_1')).toBeTruthy();
expect(within(dialog).queryByRole('textbox')).toBeNull();
});
test('详情关闭后旧响应不会覆盖新目标', async () => {
const second = {
...review,
id: 'review_2',
author: { id: 'user_2', name: '用户乙' },
comment: '第二条正文',
};
vi.mocked(listAdminGameReviews).mockResolvedValue(
list({ reviews: [review, second], total: 2 }),
);
let resolveOld!: (value: { review: AdminGameReview; operations: [] }) => void;
vi.mocked(getAdminGameReview)
.mockImplementationOnce(
() =>
new Promise<AdminGameReviewDetailResponse>((resolve) => {
resolveOld = resolve;
}),
)
.mockResolvedValue({ review: second, operations: [] });
show();
fireEvent.click(
within(await row()).getByRole('button', { name: '查看详情' }),
);
fireEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: '关闭' }),
);
fireEvent.click(
within(screen.getByText('用户乙').closest('tr')!).getByRole('button', {
name: '查看详情',
}),
);
await within(screen.getByRole('dialog')).findByText('第二条正文');
await act(async () => {
resolveOld({ review, operations: [] });
});
expect(
within(screen.getByRole('dialog')).getByText('第二条正文'),
).toBeTruthy();
expect(screen.getByRole('dialog').textContent).not.toContain(review.comment);
});
test('管理提交中切换游戏,成功后刷新当前游戏而不恢复旧筛选', async () => {
let finish!: (
value: Awaited<ReturnType<typeof moderateAdminGameReview>>,
) => void;
vi.mocked(moderateAdminGameReview).mockImplementationOnce(
() =>
new Promise<Awaited<ReturnType<typeof moderateAdminGameReview>>>(
(resolve) => {
finish = resolve;
},
),
);
window.history.replaceState(null, '', '#game-reviews?gameId=game_1');
show();
fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' }));
const dialog = screen.getByRole('dialog', { name: '隐藏评价' });
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
target: { value: '广告' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
act(() => {
window.history.replaceState(null, '', '#game-reviews?gameId=game_2');
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
await waitFor(() =>
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
'token',
expect.objectContaining({ gameId: 'game_2' }),
),
);
fireEvent.click(
within(await row()).getByRole('button', { name: '查看详情' }),
);
await within(screen.getByRole('dialog', { name: '评价详情' })).findByText(
'评价 ID',
);
await act(async () => {
finish({
review: null,
operation: {
id: 'op',
reviewId: review.id,
gameId: review.gameId,
userId: review.author.id,
reviewCreatedAt: review.createdAt,
action: 'hide',
adminUserId: 'admin',
reason: '广告',
createdAt: review.createdAt,
},
replayed: false,
});
});
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
'token',
expect.objectContaining({ gameId: 'game_2' }),
);
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
'game_2',
);
expect(screen.getByRole('dialog', { name: '评价详情' })).toBeTruthy();
});