Merge remote-tracking branch 'origin/master' into codex/tiaoyitiao
# Conflicts: # server-rs/crates/api-server/src/jump_hop.rs # server-rs/crates/api-server/src/modules/jump_hop.rs
This commit is contained in:
@@ -17,6 +17,7 @@ import type {
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
@@ -41,6 +42,7 @@ import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
@@ -51,6 +53,7 @@ import { ApiClientError } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createBarkBattleDraft,
|
||||
deleteBarkBattleWork,
|
||||
generateAllBarkBattleImageAssets,
|
||||
listBarkBattleGallery,
|
||||
listBarkBattleWorks,
|
||||
@@ -611,6 +614,7 @@ vi.mock('../../services/puzzle-runtime', () => ({
|
||||
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
createSession: vi.fn(),
|
||||
deleteWork: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
getLeaderboard: vi.fn(),
|
||||
@@ -652,6 +656,7 @@ vi.mock('../../services/big-fish-runtime', () => ({
|
||||
|
||||
vi.mock('../../services/bark-battle-creation', () => ({
|
||||
createBarkBattleDraft: vi.fn(),
|
||||
deleteBarkBattleWork: vi.fn(),
|
||||
generateAllBarkBattleImageAssets: vi.fn(),
|
||||
listBarkBattleGallery: vi.fn(),
|
||||
listBarkBattleWorks: vi.fn(),
|
||||
@@ -675,6 +680,7 @@ vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
|
||||
woodenFishClient: {
|
||||
checkpointRun: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
deleteWork: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
finishRun: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
@@ -2720,6 +2726,7 @@ beforeEach(() => {
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||||
vi.mocked(deleteBarkBattleWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
|
||||
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
|
||||
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
|
||||
@@ -2728,6 +2735,7 @@ beforeEach(() => {
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(woodenFishClient.deleteWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
|
||||
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
|
||||
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
|
||||
@@ -2736,6 +2744,7 @@ beforeEach(() => {
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(jumpHopClient.deleteWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(jumpHopClient.getSession).mockRejectedValue(
|
||||
new Error('未找到跳一跳会话'),
|
||||
);
|
||||
@@ -2748,6 +2757,7 @@ beforeEach(() => {
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(woodenFishClient.deleteWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(woodenFishClient.getSession).mockRejectedValue(
|
||||
new Error('未找到敲木鱼会话'),
|
||||
);
|
||||
@@ -4223,6 +4233,115 @@ test('background match3d draft failure notifies and reopens failed retry page',
|
||||
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('failed match3d draft retry reuses current session instead of creating another draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
const failedSession = buildMockMatch3DAgentSession({
|
||||
sessionId: 'match3d-retry-failed-session',
|
||||
draft: null,
|
||||
stage: 'collecting_config',
|
||||
updatedAt: '2026-05-18T12:05:00.000Z',
|
||||
});
|
||||
const persistedFailedWork: Match3DWorkSummary = {
|
||||
workId: 'match3d-retry-failed-work',
|
||||
profileId: 'match3d-retry-failed-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: failedSession.sessionId,
|
||||
gameName: '重试抓鹅',
|
||||
themeText: '霓虹水果摊',
|
||||
summary: '抓大鹅素材生成失败,可重新打开处理。',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-18T12:05:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
generatedItemAssets: [],
|
||||
};
|
||||
let rejectCompile!: (reason?: unknown) => void;
|
||||
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
vi.mocked(match3dCreationClient.executeAction).mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectCompile = reject;
|
||||
}),
|
||||
);
|
||||
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('抓大鹅'));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
await screen.findByRole('progressbar', { name: '抓大鹅草稿生成进度' });
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
vi.mocked(listMatch3DWorks).mockResolvedValue({
|
||||
items: [persistedFailedWork],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectCompile(new Error('抓大鹅素材服务失败'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const failureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
|
||||
|
||||
const draftPanel = getPlatformTabPanel('saves');
|
||||
await user.click(
|
||||
await within(draftPanel).findByRole('button', {
|
||||
name: /继续创作《(?:重试抓鹅|抓大鹅草稿)》/u,
|
||||
}),
|
||||
);
|
||||
const reopenedFailureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(
|
||||
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
vi.mocked(match3dCreationClient.executeAction).mockResolvedValueOnce({
|
||||
session: buildMockMatch3DAgentSession({
|
||||
sessionId: failedSession.sessionId,
|
||||
stage: 'draft_ready',
|
||||
draft: {
|
||||
profileId: persistedFailedWork.profileId,
|
||||
gameName: persistedFailedWork.gameName,
|
||||
themeText: persistedFailedWork.themeText,
|
||||
summary: persistedFailedWork.summary,
|
||||
tags: persistedFailedWork.tags,
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: persistedFailedWork.clearCount,
|
||||
difficulty: persistedFailedWork.difficulty,
|
||||
generatedItemAssets: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '重新生成草稿' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
failedSession.sessionId,
|
||||
expect.objectContaining({ action: 'match3d_compile_draft' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('running match3d persisted draft reopens progress instead of unfinished result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const runningSession = buildMockMatch3DAgentSession({
|
||||
@@ -4916,6 +5035,113 @@ test('failed parallel puzzle generations stay as separate non-generating drafts'
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
test('failed puzzle draft retry reuses current session instead of creating another draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
const failedSession = buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-retry-failed-session',
|
||||
draft: null,
|
||||
stage: 'collecting_anchors',
|
||||
updatedAt: '2026-05-18T12:00:00.000Z',
|
||||
});
|
||||
const persistedFailedWork: PuzzleWorkSummary = {
|
||||
workId: `puzzle-work-${failedSession.sessionId}`,
|
||||
profileId: `puzzle-profile-${failedSession.sessionId}`,
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: failedSession.sessionId,
|
||||
authorDisplayName: '测试玩家',
|
||||
workTitle: '',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '第1关',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-18T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
generationStatus: 'failed',
|
||||
levels: [],
|
||||
};
|
||||
let rejectCompile!: (reason?: unknown) => void;
|
||||
vi.mocked(createPuzzleAgentSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
vi.mocked(executePuzzleAgentAction).mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectCompile = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
await screen.findByRole('progressbar', { name: '拼图图片生成进度' });
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [persistedFailedWork],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectCompile(new Error('拼图图片生成失败'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const failureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
|
||||
|
||||
const draftPanel = getPlatformTabPanel('saves');
|
||||
await user.click(
|
||||
await within(draftPanel).findByRole('button', {
|
||||
name: /继续创作《[^》]+》/u,
|
||||
}),
|
||||
);
|
||||
const reopenedFailureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(
|
||||
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
|
||||
operation: {
|
||||
operationId: 'compile-puzzle-retry',
|
||||
type: 'compile_puzzle_draft',
|
||||
status: 'completed',
|
||||
phaseLabel: '已完成',
|
||||
phaseDetail: '草稿已生成',
|
||||
progress: 1,
|
||||
},
|
||||
session: buildMockPuzzleAgentSession({
|
||||
sessionId: failedSession.sessionId,
|
||||
stage: 'ready_to_publish',
|
||||
progressPercent: 100,
|
||||
draft: buildReadyPuzzleDraft(),
|
||||
}),
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '重新生成图片' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||||
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
failedSession.sessionId,
|
||||
expect.objectContaining({ action: 'compile_puzzle_draft' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('running puzzle draft opens generation progress from draft tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const runningSession = buildMockPuzzleAgentSession({
|
||||
@@ -9412,10 +9638,10 @@ test('starting draft generation leaves the agent workspace and shows the generat
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.getByText('当前世界信息')).toBeTruthy();
|
||||
expect(screen.queryByText('当前世界信息')).toBeNull();
|
||||
expect(screen.queryByText('回到工作区')).toBeNull();
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
expect(screen.getByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeTruthy();
|
||||
expect(screen.queryByText('世界承诺')).toBeNull();
|
||||
expect(screen.queryByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeNull();
|
||||
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -11641,3 +11867,145 @@ test('creation hub published work card reveals delete action after card action r
|
||||
expect(within(dialog).getByRole('button', { name: '确认删除' })).toBeTruthy();
|
||||
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creation hub gives jump hop wooden fish and bark battle cards the shared delete interaction', async () => {
|
||||
const user = userEvent.setup();
|
||||
const jumpHopWork = {
|
||||
...buildMockJumpHopWork({
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-delete',
|
||||
profileId: 'jump-hop-profile-delete',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-delete',
|
||||
workTitle: '跳台删除草稿',
|
||||
workDescription: '跳一跳草稿也应接入统一删除。',
|
||||
themeTags: ['跳台'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-21T10:20:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
}).summary,
|
||||
} satisfies JumpHopWorkSummaryResponse;
|
||||
const woodenFishWork = {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-delete',
|
||||
profileId: 'wooden-fish-profile-delete',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-delete',
|
||||
workTitle: '木鱼删除草稿',
|
||||
workDescription: '敲木鱼草稿也应接入统一删除。',
|
||||
themeTags: ['木鱼'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-21T10:10:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
} satisfies WoodenFishWorkSummaryResponse;
|
||||
const barkBattleWork = buildMockBarkBattleWork({
|
||||
workId: 'bark-battle-work-delete',
|
||||
draftId: 'bark-battle-draft-delete',
|
||||
title: '声浪删除已发布',
|
||||
summary: '汪汪声浪已发布作品也应接入统一删除。',
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
publishedAt: '2026-05-21T10:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.mocked(fetchCreationEntryConfig).mockResolvedValueOnce({
|
||||
...testCreationEntryConfig,
|
||||
creationTypes: [
|
||||
...testCreationEntryConfig.creationTypes,
|
||||
{
|
||||
id: 'jump-hop',
|
||||
title: '跳一跳',
|
||||
subtitle: '俯视角跳台挑战',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/jump-hop.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 46,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish',
|
||||
title: '敲木鱼',
|
||||
subtitle: '功德敲击小游戏',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/wooden-fish.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 47,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({
|
||||
items: [jumpHopWork],
|
||||
});
|
||||
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({
|
||||
items: [woodenFishWork],
|
||||
});
|
||||
vi.mocked(listBarkBattleWorks).mockResolvedValue({
|
||||
items: [barkBattleWork],
|
||||
});
|
||||
vi.mocked(jumpHopClient.deleteWork).mockResolvedValueOnce({ items: [] });
|
||||
vi.mocked(woodenFishClient.deleteWork).mockResolvedValueOnce({ items: [] });
|
||||
vi.mocked(deleteBarkBattleWork).mockResolvedValueOnce({ items: [] });
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openDraftHub(user);
|
||||
|
||||
async function revealAndConfirmDelete(
|
||||
cardName: RegExp,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
const card = await screen.findByRole('button', { name: cardName });
|
||||
card.focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
const shell = card.closest('.creation-work-card-shell');
|
||||
if (!shell) {
|
||||
throw new Error('作品卡应位于统一操作壳内');
|
||||
}
|
||||
await user.click(within(shell as HTMLElement).getByRole('button', { name: '删除' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
|
||||
expect(within(dialog).getByText(`确认删除《${title}》吗?`)).toBeTruthy();
|
||||
await user.click(within(dialog).getByRole('button', { name: '确认删除' }));
|
||||
}
|
||||
|
||||
await revealAndConfirmDelete(/继续创作《跳台删除草稿》/u, '跳台删除草稿');
|
||||
await waitFor(() => {
|
||||
expect(jumpHopClient.deleteWork).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-delete',
|
||||
);
|
||||
});
|
||||
|
||||
await revealAndConfirmDelete(/继续创作《木鱼删除草稿》/u, '木鱼删除草稿');
|
||||
await waitFor(() => {
|
||||
expect(woodenFishClient.deleteWork).toHaveBeenCalledWith(
|
||||
'wooden-fish-profile-delete',
|
||||
);
|
||||
});
|
||||
|
||||
await revealAndConfirmDelete(/查看详情《声浪删除已发布》/u, '声浪删除已发布');
|
||||
await waitFor(() => {
|
||||
expect(deleteBarkBattleWork).toHaveBeenCalledWith(
|
||||
'bark-battle-work-delete',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user