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>
422 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|