Files
Genarrative/src/components/game-distribution/GameReviews.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

422 lines
16 KiB
TypeScript

/* @vitest-environment jsdom */
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
GameDistributionReview,
GameDistributionReviewsResponse,
} from '../../services/gameDistributionClient';
import { GameDetailPage } from './GameDetailPage';
import { GameReviews } from './GameReviews';
import { reviewCommentLength, useGameReviews } from './useGameReviews';
const mocks = vi.hoisted(() => ({
list: vi.fn(),
mine: vi.fn(),
save: vi.fn(),
game: vi.fn(),
login: vi.fn(),
userId: 'user-1' as string | null,
}));
vi.mock('../../services/gameDistributionClient', () => ({
listGameReviews: mocks.list,
getMyGameReview: mocks.mine,
saveMyGameReview: mocks.save,
getGame: mocks.game,
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => ({
user: mocks.userId ? { id: mocks.userId } : null,
canAccessProtectedData: !!mocks.userId,
openLoginModal: mocks.login,
}),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: () => ({ resolvedUrl: '' }),
}));
function review(
overrides: Partial<GameDistributionReview> = {},
): GameDistributionReview {
return {
id: 'review-1',
gameId: 'game-1',
author: { id: 'user-1', name: '用户一' },
score: 8,
isHidden: false,
comment: '原评论',
createdAt: '2026-09-30T00:00:00Z',
updatedAt: '2026-09-30T00:00:00Z',
...overrides,
};
}
function list(
reviews: GameDistributionReview[] = [],
overrides: Partial<GameDistributionReviewsResponse> = {},
): GameDistributionReviewsResponse {
return {
reviews,
page: 1,
pageSize: 20,
total: reviews.length,
totalPages: reviews.length ? 1 : 0,
ratingSummary: {
averageScore: reviews.length ? 8 : null,
ratingCount: reviews.length,
},
...overrides,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function Harness() {
const state = useGameReviews('game-1', true);
return <GameReviews state={state} canReview onLogin={mocks.login} />;
}
async function form() {
return screen.findByRole('textbox', { name: '评论(可选)' });
}
function score(value: number) {
fireEvent.click(screen.getByRole('radio', { name: `${value} 分` }));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.userId = 'user-1';
mocks.list.mockResolvedValue(list());
mocks.mine.mockResolvedValue({ review: null });
mocks.save.mockResolvedValue({
review: review(),
ratingSummary: { averageScore: 8, ratingCount: 1 },
});
mocks.game.mockImplementation(async (id: string) => ({
id,
title: '测试游戏',
summary: '简介',
description: '介绍',
category: '益智',
tags: [],
coverColor: '#abc',
icon: '✦',
author: { id: 'author', name: '作者' },
deviceSupport: { mobile: true, desktop: true, touch: true },
status: 'published',
currentVersion: {
id: 'v1',
version: '1',
entryUrl: 'https://play.example.test/releases/v1/index.html',
controls: [],
},
playCount: 0,
}));
});
afterEach(cleanup);
describe('游戏评价', () => {
it('隐藏评价本人可见且编辑保持提示,保存后仍不参与公共统计', async () => {
mocks.mine.mockResolvedValue({ review: review({ isHidden: true }) });
mocks.save.mockResolvedValue({
review: review({ isHidden: true, score: 9, comment: '修改后的评论' }),
ratingSummary: { averageScore: null, ratingCount: 0 },
});
render(<Harness />);
expect(await screen.findByText('已被管理员隐藏')).toBeTruthy();
expect(screen.getByText('暂无评分')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '编辑评价' }));
expect(screen.getByText('已被管理员隐藏')).toBeTruthy();
score(9);
fireEvent.change(await form(), { target: { value: '修改后的评论' } });
fireEvent.click(screen.getByRole('button', { name: '提交' }));
await screen.findByRole('button', { name: '编辑评价' });
expect(mocks.save).toHaveBeenCalledWith('game-1', {
score: 9,
comment: '修改后的评论',
});
expect(screen.getByText('已被管理员隐藏')).toBeTruthy();
expect(screen.getByText('暂无评分')).toBeTruthy();
expect(screen.getByText('暂无评价')).toBeTruthy();
});
it('必选评分、首次取消清空草稿且不写入,允许空评论提交', async () => {
render(<Harness />);
const input = await form();
expect(
(screen.getByRole('button', { name: '提交' }) as HTMLButtonElement)
.disabled,
).toBe(true);
score(1);
fireEvent.change(input, { target: { value: '草稿' } });
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect((input as HTMLTextAreaElement).value).toBe('');
expect(screen.getByText('0/4000')).toBeTruthy();
expect(mocks.save).not.toHaveBeenCalled();
score(10);
fireEvent.click(screen.getByRole('button', { name: '提交' }));
await waitFor(() =>
expect(mocks.save).toHaveBeenCalledWith('game-1', {
score: 10,
comment: '',
}),
);
await screen.findByRole('button', { name: '编辑评价' });
expect(screen.queryByRole('textbox')).toBeNull();
});
it('自己的评价同时出现在个人区与公共列表,编辑预填、取消恢复、保存更新', async () => {
mocks.mine.mockResolvedValue({ review: review() });
mocks.list.mockResolvedValue(list([review()]));
render(<Harness />);
fireEvent.click(await screen.findByRole('button', { name: '编辑评价' }));
const input = await form();
expect((input as HTMLTextAreaElement).value).toBe('原评论');
expect(
(screen.getByRole('radio', { name: '8 分' }) as HTMLInputElement).checked,
).toBe(true);
fireEvent.change(input, { target: { value: '未保存' } });
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(screen.getAllByText('原评论')).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: '编辑评价' }));
fireEvent.change(await form(), { target: { value: '新评论' } });
score(9);
const updated = review({ score: 9, comment: '新评论' });
mocks.save.mockResolvedValue({
review: updated,
ratingSummary: { averageScore: 9, ratingCount: 1 },
});
mocks.list.mockResolvedValue(
list([updated], { ratingSummary: { averageScore: 9, ratingCount: 1 } }),
);
fireEvent.click(screen.getByRole('button', { name: '提交' }));
await screen.findByRole('button', { name: '编辑评价' });
expect(mocks.save).toHaveBeenCalledWith('game-1', {
score: 9,
comment: '新评论',
});
expect(screen.getByText('9.0/10 · 1 人评分')).toBeTruthy();
});
it('按 Unicode 码点计数和归一化换行,4000 可提交,4001 粘贴/输入法输入阻止提交', async () => {
expect(reviewCommentLength('中a😀\r\n \r')).toBe(6);
render(<Harness />);
const input = await form();
score(1);
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: '😀'.repeat(4001) } });
fireEvent.compositionEnd(input);
expect(screen.getByText('4001/4000')).toBeTruthy();
expect(
(screen.getByRole('button', { name: '提交' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(mocks.save).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: '😀'.repeat(4000) } });
expect(screen.getByText('4000/4000')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '提交' }));
await waitFor(() =>
expect(mocks.save).toHaveBeenCalledWith('game-1', {
score: 1,
comment: '😀'.repeat(4000),
}),
);
});
it('提交期间冻结输入与按钮,失败恢复编辑并保留草稿', async () => {
const pending = deferred<never>();
mocks.save.mockReturnValue(pending.promise);
render(<Harness />);
const input = await form();
score(7);
fireEvent.change(input, { target: { value: '保留草稿' } });
fireEvent.click(screen.getByRole('button', { name: '提交' }));
expect((input as HTMLTextAreaElement).disabled).toBe(true);
expect(
screen.getByRole('radio', { name: '7 分' }).closest('fieldset')?.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '取消' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '提交中…' }) as HTMLButtonElement)
.disabled,
).toBe(true);
await act(async () => pending.reject(new Error('保存失败')));
expect(screen.getByText('保存失败')).toBeTruthy();
expect((input as HTMLTextAreaElement).value).toBe('保留草稿');
expect((input as HTMLTextAreaElement).disabled).toBe(false);
});
it('个人读取失败不展示首次表单,摘要失败不显示暂无评分,可分别重试', async () => {
mocks.mine.mockRejectedValueOnce(new Error('个人失败'));
mocks.list.mockRejectedValueOnce(new Error('列表失败'));
render(<Harness />);
await screen.findByText('个人失败');
expect(screen.queryByRole('textbox')).toBeNull();
expect(screen.queryByText('暂无评分')).toBeNull();
const personal = document.querySelector(
'.game-reviews__personal',
) as HTMLElement;
fireEvent.click(within(personal).getByRole('button', { name: '重试' }));
await form();
fireEvent.click(
within(
document.querySelector('.game-reviews__public') as HTMLElement,
).getAllByRole('button', { name: '重试' })[0]!,
);
await screen.findByText('暂无评分');
});
it('服务端分页保留总数,修改保留当前页、首次提交回第一页', async () => {
mocks.mine.mockResolvedValue({ review: review() });
mocks.list.mockImplementation(async (_id, page) =>
list([review({ comment: `第${page}页` })], {
page,
total: 21,
totalPages: 2,
ratingSummary: { averageScore: 8.2, ratingCount: 21 },
}),
);
render(<Harness />);
await screen.findByText('第1页');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第2页');
expect(screen.getByText('8.2/10 · 21 人评分')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '编辑评价' }));
fireEvent.click(screen.getByRole('button', { name: '提交' }));
await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(3));
expect(mocks.list.mock.calls[2]).toEqual(['game-1', 2]);
cleanup();
mocks.mine.mockResolvedValue({ review: null });
mocks.list.mockClear();
render(<Harness />);
await form();
await screen.findByText('第1页');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第2页');
score(8);
fireEvent.click(screen.getByRole('button', { name: '提交' }));
await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(3));
expect(mocks.list.mock.calls[2]).toEqual(['game-1', 1]);
});
it('保存后迟到的初始列表不能覆盖新摘要或结束刷新状态', async () => {
const oldList = deferred<GameDistributionReviewsResponse>();
const refreshed = deferred<GameDistributionReviewsResponse>();
const saving = deferred<{
review: GameDistributionReview;
ratingSummary: { averageScore: number; ratingCount: number };
}>();
mocks.mine.mockResolvedValueOnce({ review: review() });
mocks.list
.mockReturnValueOnce(oldList.promise)
.mockReturnValueOnce(refreshed.promise);
mocks.save.mockReturnValueOnce(saving.promise);
render(<Harness />);
fireEvent.click(await screen.findByRole('button', { name: '编辑评价' }));
fireEvent.change(await form(), { target: { value: '已保存' } });
fireEvent.click(screen.getByRole('button', { name: '提交' }));
const updated = review({ comment: '已保存' });
await act(async () =>
saving.resolve({
review: updated,
ratingSummary: { averageScore: 9, ratingCount: 1 },
}),
);
await screen.findByText('已保存');
await act(async () => {
oldList.resolve(list());
});
expect(screen.getByText('已保存')).toBeTruthy();
expect(screen.getByText('9.0/10 · 1 人评分')).toBeTruthy();
expect(screen.getByText('正在加载评价…')).toBeTruthy();
await act(async () => refreshed.resolve(list([updated])));
expect(screen.queryByText('正在加载评价…')).toBeNull();
expect(screen.getAllByText('已保存')).toHaveLength(2);
});
it('快速翻页只接受最新请求,旧失败和加载结束不覆盖当前页', async () => {
const second = deferred<GameDistributionReviewsResponse>();
const third = deferred<GameDistributionReviewsResponse>();
mocks.list
.mockResolvedValueOnce(list([review()], { total: 41, totalPages: 3 }))
.mockReturnValueOnce(second.promise)
.mockReturnValueOnce(third.promise);
render(<Harness />);
await screen.findByText('原评论');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await act(async () => second.reject(new Error('旧页错误')));
expect(screen.getByText('正在加载评价…')).toBeTruthy();
expect(screen.queryByText('旧页错误')).toBeNull();
await act(async () =>
third.resolve(
list([review({ comment: '最新页' })], {
page: 3,
total: 41,
totalPages: 3,
}),
),
);
expect(screen.getByText('最新页')).toBeTruthy();
expect(screen.getByText('3 / 3 页')).toBeTruthy();
});
it('游客能浏览评分和空评论评价,登录入口复用平台登录', async () => {
mocks.userId = null;
mocks.list.mockResolvedValue(list([review({ comment: '' })]));
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', { name: '登录后评价' }));
expect(mocks.login).toHaveBeenCalled();
expect(mocks.mine).not.toHaveBeenCalled();
expect(screen.queryByRole('textbox')).toBeNull();
expect(await screen.findByText('8/10')).toBeTruthy();
});
it('切换账号清空草稿,切换游戏或退出时隔离旧个人响应', async () => {
const first = deferred<{ review: GameDistributionReview | null }>();
mocks.mine
.mockReturnValueOnce(first.promise)
.mockResolvedValue({ review: null });
const { rerender } = render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
await screen.findByText('正在加载我的评价…');
mocks.userId = 'user-2';
rerender(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const input = await form();
fireEvent.change(input, { target: { value: '账号二草稿' } });
await act(async () =>
first.resolve({ review: review({ comment: '账号一私有评论' }) }),
);
expect(screen.queryByText('账号一私有评论')).toBeNull();
rerender(
<GameDetailPage gameId="game-2" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(((await form()) as HTMLTextAreaElement).value).toBe('');
mocks.userId = null;
rerender(
<GameDetailPage gameId="game-2" onBack={vi.fn()} onPlay={vi.fn()} />,
);
await screen.findByRole('button', { name: '登录后评价' });
expect(screen.queryByRole('textbox')).toBeNull();
});
});