This commit is contained in:
2026-05-14 01:11:58 +08:00
parent b13870f71b
commit 5a55180b78
61 changed files with 5050 additions and 1057 deletions

View File

@@ -63,6 +63,7 @@ import {
listMatch3DGallery,
listMatch3DWorks,
} from '../../services/match3d-works';
import * as match3dGeneratedModelCache from '../../services/match3dGeneratedModelCache';
import {
createPuzzleAgentSession,
executePuzzleAgentAction,
@@ -436,7 +437,20 @@ vi.mock('../../services/match3d-works', () => ({
}));
vi.mock('../../services/match3dGeneratedModelCache', () => ({
preloadMatch3DGeneratedModelAssets: vi.fn(() => Promise.resolve()),
hasMatch3DGeneratedImageAsset: vi.fn(
(assets: Match3DWorkSummary['generatedItemAssets']) =>
Boolean(
assets?.some(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
),
),
),
preloadMatch3DGeneratedRuntimeAssets: vi.fn(() => Promise.resolve()),
}));
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
@@ -719,9 +733,11 @@ vi.mock('../match3d-result/Match3DResultView', () => ({
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
isBusy,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
isBusy?: boolean;
onCreateFromForm?: (payload: {
seedText: string;
themeText: string;
@@ -736,8 +752,12 @@ vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
<div data-testid="match3d-workspace-busy-state">
{isBusy ? 'busy' : 'idle'}
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
onCreateFromForm?.({
seedText: '赛博水果摊题材消除9次难度6',
@@ -773,6 +793,54 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
).length
}
</div>
<div data-testid="match3d-runtime-generated-asset-count">
{
generatedItemAssets.filter(
(asset) =>
asset.modelSrc?.trim() ||
asset.modelObjectKey?.trim() ||
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
) ||
asset.backgroundMusic?.audioSrc?.trim() ||
asset.clickSound?.audioSrc?.trim() ||
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
).length
}
</div>
<div data-testid="match3d-runtime-generated-item-image-count">
{
generatedItemAssets.filter(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
).length
}
</div>
<div data-testid="match3d-runtime-background-music-count">
{
generatedItemAssets.filter((asset) =>
asset.backgroundMusic?.audioSrc?.trim(),
).length
}
</div>
<div data-testid="match3d-runtime-container-ui-count">
{
generatedItemAssets.filter(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
).length
}
</div>
<button type="button" onClick={onBack}>
</button>
@@ -1618,6 +1686,23 @@ function TestWrapper({
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(
match3dGeneratedModelCache.hasMatch3DGeneratedImageAsset,
).mockImplementation((assets) =>
Boolean(
assets?.some(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
),
),
);
vi.mocked(
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
).mockResolvedValue(undefined);
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
match3dServerRuntimeAdapterMock,
);
@@ -2676,6 +2761,228 @@ test('running match3d form generation can return to draft tab and reopen progres
});
});
test('running match3d form generation keeps other creation templates available', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-running-session',
draft: null,
stage: 'collecting_config',
});
let resolveCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
vi.mocked(match3dCreationClient.executeAction).mockReturnValue(
new Promise((resolve) => {
resolveCompile = resolve;
}),
);
const puzzleReadySession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-parallel-1',
seedText: '暖灯猫街',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: buildPuzzleAnchorPack(),
draft: {
workTitle: '并行拼图',
workDescription: '抓大鹅后台生成时创建的新拼图。',
levelName: '并行拼图',
summary: '抓大鹅后台生成时创建的新拼图。',
themeTags: ['并行创作'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: buildPuzzleAnchorPack(),
candidates: [
{
candidateId: 'candidate-parallel-1',
imageSrc: '/puzzle/parallel-candidate.png',
assetId: 'asset-parallel-1',
prompt: '暖灯猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-parallel-1',
coverImageSrc: '/puzzle/parallel-candidate.png',
coverAssetId: 'asset-parallel-1',
generationStatus: 'ready',
levels: [
{
levelId: 'puzzle-level-parallel-1',
levelName: '并行拼图',
pictureDescription: '一只猫在雨夜灯牌下回头。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-parallel-1',
imageSrc: '/puzzle/parallel-candidate.png',
assetId: 'asset-parallel-1',
prompt: '暖灯猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-parallel-1',
coverImageSrc: '/puzzle/parallel-candidate.png',
coverAssetId: 'asset-parallel-1',
generationStatus: 'ready',
},
],
},
messages: [],
lastAssistantReply: '拼图草稿已经生成。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-05-13T10:00:00.000Z',
};
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-parallel-1',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: puzzleReadySession,
});
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: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
const generatePuzzleButton = await screen.findByRole('button', {
name: '生成草稿',
});
expect((generatePuzzleButton as HTMLButtonElement).disabled).toBe(false);
await user.click(generatePuzzleButton);
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
});
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-1',
expect.objectContaining({
action: 'compile_puzzle_draft',
}),
);
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCompile({ session: buildMockMatch3DAgentSession() });
});
});
test('running match3d form generation keeps same template generation available', async () => {
const user = userEvent.setup();
const firstSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-1',
draft: null,
stage: 'collecting_config',
});
const secondSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-2',
draft: null,
stage: 'collecting_config',
});
let resolveFirstCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
let resolveSecondCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.createSession)
.mockResolvedValueOnce({ session: firstSession })
.mockResolvedValueOnce({ session: secondSession });
vi.mocked(match3dCreationClient.executeAction)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstCompile = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondCompile = resolve;
}),
);
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: '返回创作中心' }));
const match3dTab = await screen.findByRole('tab', { name: '抓大鹅' });
expect((match3dTab as HTMLButtonElement).disabled).toBe(false);
await user.click(match3dTab);
const secondGenerateButton = await screen.findByRole('button', {
name: '生成抓大鹅草稿',
});
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByTestId('match3d-workspace-busy-state')).toHaveProperty(
'textContent',
'idle',
);
await user.click(secondGenerateButton);
await waitFor(() => {
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(2);
});
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(2);
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
1,
'match3d-parallel-session-1',
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
2,
'match3d-parallel-session-2',
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
expect(screen.getAllByText('抓大鹅草稿').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('生成中').length).toBeGreaterThanOrEqual(2);
});
await act(async () => {
resolveFirstCompile({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-1',
}),
});
resolveSecondCompile({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-2',
}),
});
});
});
test('match3d result trial passes generated models into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
@@ -2761,6 +3068,166 @@ test('match3d result trial passes generated models into first runtime mount', as
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
test('match3d result trial passes generated 2D image views into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
viewId: `view-${String(viewIndex).padStart(2, '0')}`,
viewIndex,
imageSrc:
`/generated-match3d-assets/session/profile/items/match3d-item-1-item/views/view-${String(viewIndex).padStart(2, '0')}.png`,
imageObjectKey: null,
})),
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
];
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-draft-2d-1',
profileId: 'match3d-profile-draft-2d-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-draft-2d-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-13T10:30:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-draft-2d-1',
draft: {
profileId: 'match3d-profile-draft-2d-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
}),
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dDraftWork,
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dDraftWork.profileId),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-draft-2d-1',
{},
);
});
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '0');
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-item-image-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
});
test('match3d result back returns to platform creation page', async () => {
const user = userEvent.setup();
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-back-1',
profileId: 'match3d-profile-back-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-back-1',
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '休闲', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-12T12:10:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets: [],
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-back-1',
draft: {
profileId: 'match3d-profile-back-1',
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '休闲', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets: [],
},
}),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
});
test('match3d draft generation auto starts trial and runtime back opens draft result', async () => {
@@ -3915,7 +4382,7 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
});
});
test('home recommendation Match3D runtime refetches detail when stale card only has image assets', async () => {
test('home recommendation Match3D runtime keeps image, music and UI assets without requiring models', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-image-only',
profileId: 'match3d-profile-card-image-only',
@@ -3949,6 +4416,108 @@ test('home recommendation Match3D runtime refetches detail when stale card only
subscriptionKey: null,
status: 'image_ready',
error: null,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc:
'/generated-match3d-assets/session/profile/audio/background.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-12T10:00:00.000Z',
},
backgroundAsset: {
prompt: '果园竖屏纯背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
},
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-card-image-only',
);
expect(
screen.getByTestId('match3d-runtime-background-music-count'),
).toHaveProperty('textContent', '1');
expect(screen.getByTestId('match3d-runtime-container-ui-count')).toHaveProperty(
'textContent',
'1',
);
});
test('home recommendation Match3D runtime reloads detail when card only has UI assets', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-ui-only',
profileId: 'match3d-profile-card-ui-only',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-ui-only',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '消除水果素材。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
backgroundAsset: {
prompt: '果园竖屏纯背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
},
},
],
};
@@ -3956,17 +4525,29 @@ test('home recommendation Match3D runtime refetches detail when stale card only
...match3dCard,
generatedItemAssets: [
{
...match3dCard.generatedItemAssets![0]!,
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: 'task-strawberry',
subscriptionKey: 'sub-strawberry',
status: 'model_ready',
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/views/view-01.png',
imageObjectKey: null,
},
],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
@@ -3981,14 +4562,12 @@ test('home recommendation Match3D runtime refetches detail when stale card only
await waitFor(() => {
expect(getMatch3DWorkDetail).toHaveBeenCalledWith(
'match3d-profile-card-image-only',
'match3d-profile-card-ui-only',
);
});
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
});
expect(
await screen.findByTestId('match3d-runtime-generated-item-image-count'),
).toHaveProperty('textContent', '1');
});
test('home recommendation surfaces start failure instead of staying in loading state', async () => {