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
公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 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>
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
/* @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();
|
||
});
|