Merge remote-tracking branch 'origin/master' into fix/test-concurrent-problem
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m48s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m15s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / Repository checks (pull_request) Failing after 22s
Project CI / Frontend tests (pull_request) Successful in 2m31s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m24s
Project CI / Native shell tests (pull_request) Successful in 6m53s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m48s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m15s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / Repository checks (pull_request) Failing after 22s
Project CI / Frontend tests (pull_request) Successful in 2m31s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m24s
Project CI / Native shell tests (pull_request) Successful in 6m53s
# Conflicts: # docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
@@ -5,12 +5,16 @@ import {
|
||||
executeAdminRechargeRefund,
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminGameReview,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminAgcTrackingEvents,
|
||||
listAdminGameDistributionGames,
|
||||
listAdminGameDistributionReviews,
|
||||
listAdminGameReviewGames,
|
||||
listAdminGameReviews,
|
||||
listAdminRechargeOrders,
|
||||
moderateAdminGameReview,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
restoreAdminGameDistributionGame,
|
||||
@@ -27,6 +31,52 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('用户评价查询编码筛选和目标,管理请求携带原创建时间及幂等key', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: {} }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await listAdminGameReviews('token', {
|
||||
gameId: 'game+1',
|
||||
userId: 'user/1',
|
||||
keyword: '中文 广告',
|
||||
status: 'hidden',
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
});
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-reviews?gameId=game%2B1&userId=user%2F1&keyword=%E4%B8%AD%E6%96%87+%E5%B9%BF%E5%91%8A&status=hidden&page=2&pageSize=20',
|
||||
);
|
||||
await listAdminGameReviewGames('token', { query: ' 中文 ', page: 1 });
|
||||
expect(fetchMock.mock.calls[1]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-review-games?query=%E4%B8%AD%E6%96%87&page=1',
|
||||
);
|
||||
await getAdminGameReview('token', '6:game/1user');
|
||||
expect(fetchMock.mock.calls[2]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-reviews/6%3Agame%2F1user',
|
||||
);
|
||||
const body = {
|
||||
action: 'delete' as const,
|
||||
expectedCreatedAt: '2026-10-01T00:00:00Z',
|
||||
reason: '广告',
|
||||
};
|
||||
await moderateAdminGameReview('token', '6:game/1user', 'same-attempt', body);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
'/admin/api/game-distribution/user-reviews/6%3Agame%2F1user/moderation',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer token',
|
||||
'Idempotency-Key': 'same-attempt',
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
|
||||
const payload = { entries: [], nextCursor: null };
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
|
||||
@@ -38,6 +38,13 @@ import type {
|
||||
AdminGameDistributionReviewListResponse,
|
||||
AdminGameDistributionReviewRequest,
|
||||
AdminGameDistributionReviewResponse,
|
||||
AdminGameReviewDetailResponse,
|
||||
AdminGameReviewGamesQuery,
|
||||
AdminGameReviewGamesResponse,
|
||||
AdminGameReviewModerationRequest,
|
||||
AdminGameReviewModerationResponse,
|
||||
AdminGameReviewsQuery,
|
||||
AdminGameReviewsResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
@@ -1243,6 +1250,61 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||
);
|
||||
}
|
||||
|
||||
function gameReviewQuery(
|
||||
query: AdminGameReviewGamesQuery | AdminGameReviewsQuery,
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && String(value).trim())
|
||||
params.set(key, String(value).trim());
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function listAdminGameReviewGames(
|
||||
token: string,
|
||||
query: AdminGameReviewGamesQuery = {},
|
||||
) {
|
||||
return request<AdminGameReviewGamesResponse>(
|
||||
`/admin/api/game-distribution/user-review-games?${gameReviewQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameReviews(
|
||||
token: string,
|
||||
query: AdminGameReviewsQuery = {},
|
||||
) {
|
||||
return request<AdminGameReviewsResponse>(
|
||||
`/admin/api/game-distribution/user-reviews?${gameReviewQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminGameReview(token: string, reviewId: string) {
|
||||
return request<AdminGameReviewDetailResponse>(
|
||||
`/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function moderateAdminGameReview(
|
||||
token: string,
|
||||
reviewId: string,
|
||||
idempotencyKey: string,
|
||||
payload: AdminGameReviewModerationRequest,
|
||||
) {
|
||||
return request<AdminGameReviewModerationResponse>(
|
||||
`/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}/moderation`,
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameDistributionGames(
|
||||
token: string,
|
||||
options: { limit?: number } = {},
|
||||
|
||||
@@ -1186,6 +1186,90 @@ export interface AdminGameDistributionRestoreResponse {
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
/** 游戏用户评价管理,与发行版本审核分开。 */
|
||||
export interface AdminGameReviewGamesQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGame {
|
||||
gameId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGamesResponse {
|
||||
games: AdminGameReviewGame[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewsQuery {
|
||||
gameId?: string;
|
||||
userId?: string;
|
||||
keyword?: string;
|
||||
status?: 'all' | 'visible' | 'hidden';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGameInfo {
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReview {
|
||||
id: string;
|
||||
gameId: string;
|
||||
game: AdminGameReviewGameInfo;
|
||||
author: { id: string; name: string; avatarUrl?: string | null };
|
||||
score: number;
|
||||
comment: string;
|
||||
isHidden: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewOperation {
|
||||
id: string;
|
||||
reviewId: string;
|
||||
gameId: string;
|
||||
userId: string;
|
||||
reviewCreatedAt: string;
|
||||
action: string;
|
||||
adminUserId: string;
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewsResponse {
|
||||
reviews: AdminGameReview[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewDetailResponse {
|
||||
review: AdminGameReview;
|
||||
operations: AdminGameReviewOperation[];
|
||||
}
|
||||
|
||||
export interface AdminGameReviewModerationRequest {
|
||||
action: 'hide' | 'restore' | 'delete';
|
||||
expectedCreatedAt: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewModerationResponse {
|
||||
review: AdminGameReview | null;
|
||||
operation: AdminGameReviewOperation;
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplatePayload {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseRevie
|
||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
|
||||
import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
@@ -141,7 +142,7 @@ export function AdminApp() {
|
||||
|
||||
setRouteId(nextRouteId);
|
||||
const nextHash = routeHash(nextRouteId);
|
||||
if (window.location.hash !== nextHash) {
|
||||
if ((window.location.hash.split('?')[0] ?? '').toLowerCase() !== nextHash) {
|
||||
window.history.replaceState(null, '', nextHash);
|
||||
}
|
||||
}, [accessibleRoutes, admin, routeId, status]);
|
||||
@@ -325,6 +326,15 @@ export function AdminApp() {
|
||||
) : null}
|
||||
{activeRouteId === 'game-management' ? (
|
||||
<AdminGameManagementPage
|
||||
token={token}
|
||||
canManageReviews={accessibleRoutes.some(
|
||||
(route) => route.id === 'game-reviews',
|
||||
)}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'game-reviews' ? (
|
||||
<AdminGameReviewsPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
|
||||
@@ -55,6 +55,7 @@ const routeIcons = {
|
||||
'editor-showcase': Star,
|
||||
'game-distribution': Gamepad2,
|
||||
'game-management': Swords,
|
||||
'game-reviews': Star,
|
||||
'editor-assets': Images,
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
routeHash,
|
||||
} from './adminRoutes';
|
||||
|
||||
test('游戏评价路由独立授权并支持游戏参数', () => {
|
||||
expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-reviews'],
|
||||
}).map((route) => route.id),
|
||||
).toEqual(['game-reviews']);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-management'],
|
||||
}).some((route) => route.id === 'game-reviews'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('客户端埋点路由遵守成员页签权限', () => {
|
||||
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
|
||||
expect(
|
||||
|
||||
@@ -18,6 +18,7 @@ export type AdminRouteId =
|
||||
| 'editor-showcase'
|
||||
| 'game-distribution'
|
||||
| 'game-management'
|
||||
| 'game-reviews'
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
@@ -62,6 +63,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
|
||||
{ id: 'game-reviews', label: '游戏评价', hash: '#game-reviews' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
|
||||
@@ -117,6 +117,20 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('有评价权限时游戏管理提供带gameId的评价入口', async () => {
|
||||
window.history.replaceState(null, '', '#game-management');
|
||||
render(
|
||||
<AdminGameManagementPage
|
||||
token="token"
|
||||
canManageReviews
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看评价' }));
|
||||
expect(window.location.hash).toBe('#game-reviews?gameId=game_1');
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -28,6 +28,7 @@ import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminGameManagementPageProps {
|
||||
token: string;
|
||||
canManageReviews?: boolean;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
@@ -86,6 +87,7 @@ function createGameActionIdempotencyKey(
|
||||
|
||||
export function AdminGameManagementPage({
|
||||
token,
|
||||
canManageReviews = false,
|
||||
onUnauthorized,
|
||||
}: AdminGameManagementPageProps) {
|
||||
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
|
||||
@@ -327,6 +329,17 @@ export function AdminGameManagementPage({
|
||||
>
|
||||
版本历史
|
||||
</AdminButton>
|
||||
{canManageReviews ? (
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = `#game-reviews?gameId=${encodeURIComponent(entry.gameId)}`;
|
||||
}}
|
||||
>
|
||||
查看评价
|
||||
</AdminButton>
|
||||
) : null}
|
||||
</AdminActionRow>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
/* @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();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -766,6 +766,33 @@ button:disabled {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-review-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-review-comment {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-table .admin-review-comment {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-review-comment-summary {
|
||||
min-width: 160px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.admin-review-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-table-query-grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
|
||||
@@ -212,7 +212,7 @@ async fn run_sidecar_turn(
|
||||
.take()
|
||||
.ok_or_else(|| "Claude Agent SDK sidecar stderr 未建立".to_string())?;
|
||||
let stderr_task = tokio::spawn(read_capped(stderr, CLAUDE_CODE_OUTPUT_MAX_BYTES));
|
||||
let result = tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
|
||||
let result = match tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
let mut events = Vec::new();
|
||||
let mut terminal = None;
|
||||
@@ -257,7 +257,21 @@ async fn run_sidecar_turn(
|
||||
Ok(SidecarTurnResult { events, result })
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Agent SDK sidecar 回合超时".to_string())??;
|
||||
{
|
||||
Ok(result) => result?,
|
||||
Err(_) => {
|
||||
let detail = stderr_task
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.map(|value| value.trim().chars().take(512).collect::<String>())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!(":{value}"))
|
||||
.unwrap_or_default();
|
||||
return Err(format!("Claude Agent SDK sidecar 回合超时{detail}"));
|
||||
}
|
||||
};
|
||||
let _ = stderr_task.await;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -318,6 +332,14 @@ pub(crate) fn game_creator_claude_code_cli_route_error(
|
||||
if model.is_empty() || model.chars().count() > CLAUDE_CODE_MODEL_MAX_CHARS {
|
||||
return Some("cc 模式需要有效的 Claude Code 模型标识".to_string());
|
||||
}
|
||||
if llm.api_key.trim().is_empty()
|
||||
&& !llm.custom_enabled
|
||||
&& std::env::var_os("ANTHROPIC_API_KEY").is_none()
|
||||
&& std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none()
|
||||
&& crate::platform_session::current_platform_session().is_none()
|
||||
{
|
||||
return Some("cc 模式需要先登录陶泥儿账号".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -386,12 +408,44 @@ fn configure_claude_code_environment(
|
||||
{
|
||||
command.env("ANTHROPIC_AUTH_TOKEN", llm.api_key.trim());
|
||||
}
|
||||
// Official AGC model entries use the account session rather than a
|
||||
// user-supplied API key. The Claude SDK only reads Anthropic auth
|
||||
// from its child-process environment, so bridge the already-held
|
||||
// platform token without persisting or exposing it in argv/logs.
|
||||
if llm.api_key.trim().is_empty()
|
||||
&& !llm.custom_enabled
|
||||
&& std::env::var_os("ANTHROPIC_API_KEY").is_none()
|
||||
&& std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none()
|
||||
{
|
||||
if let Some(session) = crate::platform_session::current_platform_session() {
|
||||
command.env("ANTHROPIC_AUTH_TOKEN", session.access_token);
|
||||
}
|
||||
}
|
||||
if !llm.model.trim().is_empty() {
|
||||
command.env("ANTHROPIC_MODEL", llm.model.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_result_error_detail(value: &serde_json::Value) -> Option<String> {
|
||||
let candidates = [
|
||||
value.get("result").and_then(serde_json::Value::as_str),
|
||||
value.get("error").and_then(serde_json::Value::as_str),
|
||||
value.get("message").and_then(serde_json::Value::as_str),
|
||||
value
|
||||
.get("errors")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|errors| errors.first())
|
||||
.and_then(serde_json::Value::as_str),
|
||||
];
|
||||
candidates
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(str::trim)
|
||||
.find(|detail| !detail.is_empty())
|
||||
.map(|detail| detail.chars().take(512).collect())
|
||||
}
|
||||
|
||||
fn configure_claude_code_process(command: &mut tokio::process::Command) {
|
||||
super::codex_cli::configure_game_creator_codex_cli_process(command);
|
||||
}
|
||||
@@ -496,9 +550,12 @@ fn parse_claude_code_result(
|
||||
platform_llm::LlmError::Deserialize("Claude Code JSON 输出无效".to_string())
|
||||
})?;
|
||||
if value.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||
return Err(platform_llm::LlmError::Transport(
|
||||
"Claude Code 返回失败终态".to_string(),
|
||||
));
|
||||
let detail = claude_result_error_detail(&value)
|
||||
.map(|detail| format!(":{detail}"))
|
||||
.unwrap_or_default();
|
||||
return Err(platform_llm::LlmError::Transport(format!(
|
||||
"Claude Code 返回失败终态{detail}"
|
||||
)));
|
||||
}
|
||||
let response_id = value
|
||||
.get("session_id")
|
||||
@@ -749,7 +806,10 @@ fn parse_direct_stream_result(
|
||||
}
|
||||
} else if event_type == Some("result") {
|
||||
if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||
return Err("Claude Code 返回失败终态".to_string());
|
||||
let detail = claude_result_error_detail(&event)
|
||||
.map(|detail| format!(":{detail}"))
|
||||
.unwrap_or_default();
|
||||
return Err(format!("Claude Code 返回失败终态{detail}"));
|
||||
}
|
||||
result = event
|
||||
.get("result")
|
||||
@@ -804,6 +864,16 @@ mod tests {
|
||||
assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_claude_terminal_error_detail() {
|
||||
let error = parse_claude_code_result(
|
||||
br#"{"is_error":true,"result":"Authentication failed"}"#,
|
||||
&request(false),
|
||||
)
|
||||
.expect_err("failed terminal must be rejected");
|
||||
assert!(error.to_string().contains("Authentication failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_direct_stream_result_and_final_text() {
|
||||
let mut observed = Vec::new();
|
||||
@@ -822,4 +892,16 @@ mod tests {
|
||||
[DirectCodexTurnObservation::AgentMessageSegment(_)]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_direct_stream_terminal_error_detail() {
|
||||
let error = parse_direct_stream_result(
|
||||
br#"{"type":"result","is_error":true,"result":"Authentication failed"}
|
||||
"#,
|
||||
"turn-1",
|
||||
None,
|
||||
)
|
||||
.expect_err("failed terminal must be rejected");
|
||||
assert!(error.contains("Authentication failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,66 @@ import type { HomeCreationType } from '../../view/home/useHomeDraftStore';
|
||||
export const HOME_WEB_PREFLIGHT_FAILURE =
|
||||
'Web 游戏环境预检未通过,请检查 Node/npm 或浏览器后重试;尚未开始生成。';
|
||||
|
||||
export const HOME_WEB_PREFLIGHT_IPC_FAILURE =
|
||||
'Web 游戏环境预检调用失败,客户端连接异常;请重启客户端后重试,尚未开始生成。';
|
||||
|
||||
type HomeWebPreflightReport = {
|
||||
status?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
function knownPreflightCode(value: unknown): string | undefined {
|
||||
const text = value instanceof Error ? value.message : value;
|
||||
if (typeof text !== 'string') return undefined;
|
||||
const match = text.match(
|
||||
/(?:预检未通过:|预检未通过(|code[=:])([a-z0-9-]+)/i,
|
||||
);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export function homeWebPreflightErrorMessage(value: unknown): string {
|
||||
if (value instanceof Error && value.message === HOME_WEB_PREFLIGHT_FAILURE) {
|
||||
return HOME_WEB_PREFLIGHT_FAILURE;
|
||||
}
|
||||
const code = knownPreflightCode(value);
|
||||
if (code) return `Web 游戏环境预检未通过(${code});尚未开始生成。`;
|
||||
return HOME_WEB_PREFLIGHT_IPC_FAILURE;
|
||||
}
|
||||
|
||||
function isHostPreflightError(value: unknown): boolean {
|
||||
if (!(value instanceof Error)) return false;
|
||||
return (
|
||||
value.message === HOME_WEB_PREFLIGHT_FAILURE ||
|
||||
Boolean(knownPreflightCode(value.message))
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureHomeWebCreationEnvironment(
|
||||
invoke: TauriInvoke,
|
||||
creationType: HomeCreationType,
|
||||
startMode: ProjectStartMode,
|
||||
): Promise<void> {
|
||||
if (creationType !== 'game' || startMode !== 'direct-build') return;
|
||||
try {
|
||||
const report = await invoke<{ status: string }>(
|
||||
'preflight_web_game_creation',
|
||||
);
|
||||
if (report?.status !== 'ready') throw new Error(HOME_WEB_PREFLIGHT_FAILURE);
|
||||
} catch {
|
||||
// 不把子进程、Provider 或宿主路径的原始错误注入首页状态栏。
|
||||
throw new Error(HOME_WEB_PREFLIGHT_FAILURE);
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const report = await invoke<HomeWebPreflightReport>(
|
||||
'preflight_web_game_creation',
|
||||
);
|
||||
if (report?.status === 'ready') return;
|
||||
throw new Error(
|
||||
report?.code
|
||||
? `Web 游戏环境预检未通过:${report.code}`
|
||||
: HOME_WEB_PREFLIGHT_FAILURE,
|
||||
);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const code = knownPreflightCode(error);
|
||||
if (isHostPreflightError(error) || code || attempt === 1) {
|
||||
throw new Error(homeWebPreflightErrorMessage(error));
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
throw new Error(homeWebPreflightErrorMessage(lastError));
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ import { importDesignFiles } from '../project-workspace/importDesignFiles';
|
||||
import { clearAclElevationDenials } from './aclElevation';
|
||||
import {
|
||||
ensureHomeWebCreationEnvironment,
|
||||
HOME_WEB_PREFLIGHT_FAILURE,
|
||||
homeWebPreflightErrorMessage,
|
||||
} from './homeWebPreflight';
|
||||
import type { ShowTransientNotice } from './model';
|
||||
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
|
||||
@@ -458,9 +458,10 @@ export function useHomeProjectCreation({
|
||||
}
|
||||
try {
|
||||
await ensureHomeWebCreationEnvironment(invoke, creationType, startMode);
|
||||
} catch {
|
||||
setStatus(HOME_WEB_PREFLIGHT_FAILURE);
|
||||
return HOME_WEB_PREFLIGHT_FAILURE;
|
||||
} catch (error) {
|
||||
const message = homeWebPreflightErrorMessage(error);
|
||||
setStatus(message);
|
||||
return message;
|
||||
}
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'init_local_game_project',
|
||||
@@ -894,7 +895,8 @@ export function useHomeProjectCreation({
|
||||
draft.creationType,
|
||||
startMode,
|
||||
);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const message = homeWebPreflightErrorMessage(error);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(
|
||||
operation,
|
||||
@@ -902,7 +904,7 @@ export function useHomeProjectCreation({
|
||||
operationScope(),
|
||||
),
|
||||
);
|
||||
return HOME_WEB_PREFLIGHT_FAILURE;
|
||||
return message;
|
||||
}
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
|
||||
@@ -3,7 +3,10 @@ import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { HOME_WEB_PREFLIGHT_FAILURE } from '../src/features/app-shell/homeWebPreflight';
|
||||
import {
|
||||
HOME_WEB_PREFLIGHT_FAILURE,
|
||||
HOME_WEB_PREFLIGHT_IPC_FAILURE,
|
||||
} from '../src/features/app-shell/homeWebPreflight';
|
||||
import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -67,26 +70,58 @@ it('awaits the real home preflight before naming or creating a game', async () =
|
||||
expect(result.current.currentProjectContext?.creationType).toBe('game');
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'blocks naming, creation, and first generation when preflight fails (throws=%s)',
|
||||
async (throws) => {
|
||||
const { result, calls } = mount(async () => {
|
||||
if (throws) throw new Error('private host path');
|
||||
return { status: 'blocked' };
|
||||
});
|
||||
let status = '';
|
||||
await act(async () => {
|
||||
status = await result.current.createHomeDraftAutomatically(
|
||||
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
|
||||
'direct-build',
|
||||
);
|
||||
});
|
||||
expect(status).toBe(HOME_WEB_PREFLIGHT_FAILURE);
|
||||
expect(calls).toEqual(['preflight_web_game_creation']);
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
expect(status).not.toContain('private');
|
||||
},
|
||||
);
|
||||
it('blocks naming, creation, and first generation with the host error code', async () => {
|
||||
const { result, calls } = mount(async () => {
|
||||
throw new Error(
|
||||
'Web 游戏环境预检未通过:web-preflight-browser-failed;尚未启动生成',
|
||||
);
|
||||
});
|
||||
let status = '';
|
||||
await act(async () => {
|
||||
status = await result.current.createHomeDraftAutomatically(
|
||||
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
|
||||
'direct-build',
|
||||
);
|
||||
});
|
||||
expect(status).toBe(
|
||||
'Web 游戏环境预检未通过(web-preflight-browser-failed);尚未开始生成。',
|
||||
);
|
||||
expect(calls).toEqual(['preflight_web_game_creation']);
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
});
|
||||
|
||||
it('distinguishes an IPC failure from a host preflight failure', async () => {
|
||||
const { result, calls } = mount(async () => {
|
||||
throw new Error('Failed to fetch');
|
||||
});
|
||||
let status = '';
|
||||
await act(async () => {
|
||||
status = await result.current.createHomeDraftAutomatically(
|
||||
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
|
||||
'direct-build',
|
||||
);
|
||||
});
|
||||
expect(status).toBe(HOME_WEB_PREFLIGHT_IPC_FAILURE);
|
||||
expect(calls).toEqual([
|
||||
'preflight_web_game_creation',
|
||||
'preflight_web_game_creation',
|
||||
]);
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the legacy fallback for a blocked report without a code', async () => {
|
||||
const { result, calls } = mount(async () => ({ status: 'blocked' }));
|
||||
let status = '';
|
||||
await act(async () => {
|
||||
status = await result.current.createHomeDraftAutomatically(
|
||||
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
|
||||
'direct-build',
|
||||
);
|
||||
});
|
||||
expect(status).toBe(HOME_WEB_PREFLIGHT_FAILURE);
|
||||
expect(calls).toEqual(['preflight_web_game_creation']);
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['game', 'doc'] as const)(
|
||||
'does not require the Web preflight for %s planning',
|
||||
|
||||
Reference in New Issue
Block a user