This commit is contained in:
2026-05-14 13:40:50 +08:00
parent 5a55180b78
commit 2dc9d752e4
24 changed files with 1873 additions and 98 deletions

View File

@@ -18,7 +18,11 @@ import type {
PuzzleAnchorPack,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
@@ -183,6 +187,15 @@ async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
).toBeTruthy();
}
async function expectDraftHubGeneratingBadgeCountAtLeast(count: number) {
const panel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(within(panel).getAllByText('生成中').length).toBeGreaterThanOrEqual(
count,
);
});
}
async function openDiscoverHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '发现');
const panel = getPlatformTabPanel('category');
@@ -450,6 +463,16 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({
),
),
),
normalizeMatch3DGeneratedItemAssetsForRuntime: vi.fn(
(assets: Match3DWorkSummary['generatedItemAssets']) =>
assets ? [...assets] : [],
),
mergeMatch3DGeneratedItemAssetsForRuntime: vi.fn(
(
primaryAssets: Match3DWorkSummary['generatedItemAssets'],
fallbackAssets: Match3DWorkSummary['generatedItemAssets'],
) => (primaryAssets ? [...primaryAssets] : fallbackAssets ? [...fallbackAssets] : []),
),
preloadMatch3DGeneratedRuntimeAssets: vi.fn(() => Promise.resolve()),
}));
@@ -541,22 +564,21 @@ vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
isBusy,
error,
onBack,
onExecuteAction,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onCreateFromForm?: (payload: {
seedText: string;
workTitle: string;
workDescription: string;
pictureDescription: string;
referenceImageSrc: string | null;
}) => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
}) => (
<div className="puzzle-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
<div data-testid="puzzle-workspace-busy-state">
{isBusy ? 'busy' : 'idle'}
</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
@@ -565,13 +587,23 @@ vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
type="button"
disabled={isBusy}
onClick={() => {
onCreateFromForm?.({
const payload = {
seedText: '暖灯猫街',
workTitle: '暖灯猫街',
workDescription: '一套雨夜猫街主题拼图。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
});
};
if (session) {
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: payload.pictureDescription,
...payload,
candidateCount: 1,
});
return;
}
onCreateFromForm?.(payload);
}}
>
稿
@@ -1209,6 +1241,27 @@ function buildPuzzleAnchorPack(): PuzzleAnchorPack {
};
}
function buildMockPuzzleAgentSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
return {
sessionId: 'puzzle-session-1',
seedText: '暖灯猫街',
currentTurn: 0,
progressPercent: 0,
stage: 'collecting_anchors',
anchorPack: buildPuzzleAnchorPack(),
draft: null,
messages: [],
lastAssistantReply: '先说一个你最想做成拼图的画面。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-05-14T10:00:00.000Z',
...overrides,
};
}
function buildClearedPuzzleRun(params: {
runId: string;
entryProfileId: string;
@@ -1700,6 +1753,71 @@ beforeEach(() => {
),
),
);
vi.mocked(
match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime,
).mockImplementation((assets) => {
if (!assets?.length) {
return [];
}
const musicCarrier = assets.find((asset) =>
asset.backgroundMusic?.audioSrc?.trim(),
);
if (!musicCarrier) {
return [...assets];
}
return assets.map((asset, index) =>
index === 0
? {
...asset,
backgroundMusic: asset.backgroundMusic ?? musicCarrier.backgroundMusic,
}
: {
...asset,
backgroundMusic: null,
backgroundMusicTitle: null,
backgroundMusicStyle: null,
backgroundMusicPrompt: null,
}
);
});
vi.mocked(
match3dGeneratedModelCache.mergeMatch3DGeneratedItemAssetsForRuntime,
).mockImplementation((primaryAssets, fallbackAssets) => {
const primary = primaryAssets ?? [];
const fallback = fallbackAssets ?? [];
if (primary.length <= 0) {
return match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime(
fallback,
);
}
if (fallback.length <= 0) {
return match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime(
primary,
);
}
const fallbackById = new Map(fallback.map((asset) => [asset.itemId, asset]));
return match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime(
primary.map((asset) => {
const fallbackAsset = fallbackById.get(asset.itemId);
return fallbackAsset
? {
...asset,
imageSrc: asset.imageSrc ?? fallbackAsset.imageSrc ?? null,
imageObjectKey:
asset.imageObjectKey ?? fallbackAsset.imageObjectKey ?? null,
imageViews:
asset.imageViews && asset.imageViews.length > 0
? asset.imageViews
: (fallbackAsset.imageViews ?? []),
backgroundMusic:
asset.backgroundMusic ?? fallbackAsset.backgroundMusic ?? null,
backgroundAsset:
asset.backgroundAsset ?? fallbackAsset.backgroundAsset ?? null,
}
: asset;
}),
);
});
vi.mocked(
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
).mockResolvedValue(undefined);
@@ -2749,7 +2867,7 @@ test('running match3d form generation can return to draft tab and reopen progres
await openDraftHub(user);
expect(await screen.findByText('抓大鹅草稿')).toBeTruthy();
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await user.click(
screen.getByRole('button', { name: /稿/u }),
@@ -2761,6 +2879,93 @@ test('running match3d form generation can return to draft tab and reopen progres
});
});
test('running match3d persisted draft reopens progress instead of unfinished result', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-running-persisted-session',
draft: null,
stage: 'collecting_config',
});
const persistedRunningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-running-persisted-session',
stage: 'draft_ready',
draft: {
profileId: 'match3d-running-persisted-profile',
gameName: '赛博水果摊',
themeText: '赛博水果摊',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets: [],
},
});
const persistedRunningWork: Match3DWorkSummary = {
workId: 'match3d-running-persisted-work',
profileId: 'match3d-running-persisted-profile',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-running-persisted-session',
gameName: '赛博水果摊',
themeText: '赛博水果摊',
summary: '正在生成玩法素材。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-14T10:30:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets: [],
};
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
vi.mocked(match3dCreationClient.executeAction).mockRejectedValueOnce(
new Error('素材生成仍在后台处理'),
);
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: persistedRunningSession,
});
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [persistedRunningWork],
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: persistedRunningWork,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findAllByText('素材生成仍在后台处理'),
).not.toHaveLength(0);
vi.mocked(match3dCreationClient.getSession).mockClear();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(match3dCreationClient.getSession).toHaveBeenCalledWith(
'match3d-running-persisted-session',
);
});
test('running match3d form generation keeps other creation templates available', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
@@ -2966,8 +3171,8 @@ test('running match3d form generation keeps same template generation available',
await openDraftHub(user);
await waitFor(() => {
expect(screen.getAllByText('抓大鹅草稿').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('生成中').length).toBeGreaterThanOrEqual(2);
});
await expectDraftHubGeneratingBadgeCountAtLeast(2);
await act(async () => {
resolveFirstCompile({
@@ -2983,6 +3188,126 @@ test('running match3d form generation keeps same template generation available',
});
});
test('running puzzle form generation creates a new puzzle draft on same template submit', async () => {
const user = userEvent.setup();
const firstSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-1',
});
const secondSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-2',
});
let resolveFirstCompile!: (value: {
operation: {
operationId: string;
type: 'compile_puzzle_draft';
status: 'completed';
phaseLabel: string;
phaseDetail: string;
progress: number;
};
session: PuzzleAgentSessionSnapshot;
}) => void;
let resolveSecondCompile!: (value: {
operation: {
operationId: string;
type: 'compile_puzzle_draft';
status: 'completed';
phaseLabel: string;
phaseDetail: string;
progress: number;
};
session: PuzzleAgentSessionSnapshot;
}) => void;
vi.mocked(createPuzzleAgentSession)
.mockResolvedValueOnce({ session: firstSession })
.mockResolvedValueOnce({ session: secondSession });
vi.mocked(executePuzzleAgentAction)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstCompile = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondCompile = resolve;
}),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(screen.getByTestId('puzzle-workspace-busy-state')).toHaveProperty(
'textContent',
'idle',
);
const secondGenerateButton = await screen.findByRole('button', {
name: '生成草稿',
});
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
await user.click(secondGenerateButton);
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2);
});
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
1,
'puzzle-parallel-session-1',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
2,
'puzzle-parallel-session-2',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
expect(screen.getAllByText('拼图草稿').length).toBeGreaterThanOrEqual(2);
});
await expectDraftHubGeneratingBadgeCountAtLeast(2);
await act(async () => {
resolveFirstCompile({
operation: {
operationId: 'compile-puzzle-parallel-1',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-1',
}),
});
resolveSecondCompile({
operation: {
operationId: 'compile-puzzle-parallel-2',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-2',
}),
});
});
});
test('match3d result trial passes generated models into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
@@ -3318,6 +3643,134 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
expect(screen.getByText('自动试玩抓大鹅')).toBeTruthy();
});
test('completed match3d draft notice first opens trial then reopens result', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
{
itemId: 'match3d-notice-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-notice-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-notice-item-1-item/image.png',
imageViews: [],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: 'task-notice-strawberry',
subscriptionKey: 'sub-notice-strawberry',
status: 'image_ready',
error: null,
},
];
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-notice-session-1',
draft: null,
stage: 'collecting_config',
});
const generatedSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-notice-session-1',
stage: 'draft_ready',
draft: {
profileId: 'match3d-notice-profile-1',
gameName: '红点自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
});
const generatedProfile: Match3DWorkSummary = {
workId: 'match3d-notice-work-1',
profileId: 'match3d-notice-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-notice-session-1',
gameName: '红点自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
let resolveCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.executeAction).mockReturnValue(
new Promise((resolve) => {
resolveCompile = resolve;
}),
);
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: generatedSession,
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: generatedProfile,
});
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [generatedProfile],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(generatedProfile.profileId),
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await act(async () => {
resolveCompile({ session: generatedSession });
});
expect(await screen.findByLabelText('新生成完成')).toBeTruthy();
await user.click(
await screen.findByRole('button', {
name: //u,
}),
);
expect(await screen.findByText(//u)).toBeTruthy();
expect(screen.queryByText('抓大鹅草稿生成进度')).toBeNull();
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(screen.queryByLabelText('新生成完成')).toBeNull();
await user.click(
await screen.findByRole('button', {
name: //u,
}),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
});
test('puzzle draft generation auto starts trial and runtime back opens draft result', async () => {
const user = userEvent.setup();
const generatedDraft: PuzzleResultDraft = {
@@ -3364,6 +3817,22 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/auto-candidate.png',
coverAssetId: 'asset-1',
uiBackgroundPrompt: '水果乐园竖屏纯背景',
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
backgroundMusic: {
taskId: 'music-task-auto-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-auto-1',
assetKind: 'puzzle_background_music',
audioSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
prompt: '',
title: '水果乐园',
updatedAt: '2026-05-14T10:00:00.000Z',
},
generationStatus: 'ready',
},
],
@@ -3412,6 +3881,16 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
expect.objectContaining({
levelName: '雨夜猫街',
coverImageSrc: '/puzzle/auto-candidate.png',
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
}),
}),
],
}),
);
expect(screen.queryByText('拼图结果页')).toBeNull();
@@ -4958,9 +5437,10 @@ test('puzzle draft result back button returns to creation hub', async () => {
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(
screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeTruthy();
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeNull();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
@@ -5872,7 +6352,7 @@ test('running custom world draft generation can return to creation center with s
await openDraftHub(user);
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
});
test('refresh restores running draft generation progress instead of agent workspace', async () => {