Add generationStatus and match3d/runtime fixes
Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
This commit is contained in:
@@ -1526,6 +1526,65 @@ function buildMockPuzzleAgentSession(
|
||||
};
|
||||
}
|
||||
|
||||
function buildReadyPuzzleDraft(
|
||||
overrides: Partial<PuzzleResultDraft> = {},
|
||||
): PuzzleResultDraft {
|
||||
return {
|
||||
workTitle: '自动恢复拼图',
|
||||
workDescription: '前端断连后复读 session 恢复的拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜', '拼图'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: buildPuzzleAnchorPack(),
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/recovered-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/recovered-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/recovered-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/recovered-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
uiBackgroundPrompt: '雨夜猫街竖屏纯背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClearedPuzzleRun(params: {
|
||||
runId: string;
|
||||
entryProfileId: string;
|
||||
@@ -1591,6 +1650,20 @@ function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
const match3DGeneratedUiAsset = {
|
||||
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,
|
||||
} satisfies NonNullable<Match3DWorkSummary['generatedBackgroundAsset']>;
|
||||
|
||||
function buildMockMatch3DAgentSession(
|
||||
overrides: Partial<Match3DAgentSessionSnapshot> = {},
|
||||
): Match3DAgentSessionSnapshot {
|
||||
@@ -3829,6 +3902,7 @@ test('match3d result trial passes generated models into first runtime mount', as
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
backgroundAsset: match3DGeneratedUiAsset,
|
||||
},
|
||||
];
|
||||
const match3dDraftWork: Match3DWorkSummary = {
|
||||
@@ -4076,6 +4150,7 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
backgroundAsset: match3DGeneratedUiAsset,
|
||||
},
|
||||
];
|
||||
const generatedSession = buildMockMatch3DAgentSession({
|
||||
@@ -4139,6 +4214,26 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(
|
||||
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
}),
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
@@ -4146,6 +4241,110 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
|
||||
expect(screen.getByText('自动试玩抓大鹅')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('match3d result trial loads generated background and container assets', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
|
||||
{
|
||||
itemId: 'match3d-trial-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-trial-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-trial-item-1-item/image.png',
|
||||
imageViews: [],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
backgroundAsset: match3DGeneratedUiAsset,
|
||||
},
|
||||
];
|
||||
const match3dDraftWork: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-trial-ui',
|
||||
profileId: 'match3d-profile-trial-ui',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-trial-ui',
|
||||
gameName: '手动试玩抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-14T11:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generatedBackgroundAsset: null,
|
||||
generatedItemAssets,
|
||||
};
|
||||
|
||||
vi.mocked(listMatch3DWorks).mockResolvedValue({
|
||||
items: [match3dDraftWork],
|
||||
});
|
||||
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
|
||||
session: buildMockMatch3DAgentSession({
|
||||
sessionId: 'match3d-session-trial-ui',
|
||||
stage: 'draft_ready',
|
||||
draft: {
|
||||
profileId: 'match3d-profile-trial-ui',
|
||||
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: '试玩' }));
|
||||
|
||||
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(
|
||||
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
}),
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
});
|
||||
|
||||
test('completed match3d draft notice first opens trial then reopens result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
|
||||
@@ -4164,6 +4363,7 @@ test('completed match3d draft notice first opens trial then reopens result', asy
|
||||
subscriptionKey: 'sub-notice-strawberry',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
backgroundAsset: match3DGeneratedUiAsset,
|
||||
},
|
||||
];
|
||||
const runningSession = buildMockMatch3DAgentSession({
|
||||
@@ -4257,6 +4457,14 @@ test('completed match3d draft notice first opens trial then reopens result', asy
|
||||
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
|
||||
expect(screen.queryByText('抓大鹅草稿生成进度')).toBeNull();
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
@@ -4383,51 +4591,14 @@ test('completed baby object match draft shows unread marker after leaving genera
|
||||
|
||||
test('puzzle draft generation auto starts trial and runtime back opens draft result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedDraft: PuzzleResultDraft = {
|
||||
const generatedDraft = buildReadyPuzzleDraft({
|
||||
workTitle: '自动试玩拼图',
|
||||
workDescription: '生成完成后直接试玩。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜', '拼图'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: buildPuzzleAnchorPack(),
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/auto-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/auto-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
...buildReadyPuzzleDraft().levels![0]!,
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
uiBackgroundPrompt: '水果乐园竖屏纯背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
@@ -4443,10 +4614,9 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
|
||||
title: '水果乐园',
|
||||
updatedAt: '2026-05-14T10:00:00.000Z',
|
||||
},
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const generatedSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-auto-1',
|
||||
seedText: '屋檐下的猫与暖灯街角。',
|
||||
@@ -4530,6 +4700,63 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
|
||||
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('embedded puzzle form recovers when compile request times out after backend completion', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedDraft = buildReadyPuzzleDraft();
|
||||
const generatedSession = buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-session-recovered',
|
||||
stage: 'ready_to_publish',
|
||||
progressPercent: 100,
|
||||
draft: generatedDraft,
|
||||
lastAssistantReply: '拼图草稿已经生成。',
|
||||
resultPreview: {
|
||||
draft: generatedDraft,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
},
|
||||
updatedAt: '2026-05-12T10:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
|
||||
session: buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-session-recovered',
|
||||
}),
|
||||
});
|
||||
vi.mocked(executePuzzleAgentAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error('请求超时:90000ms'), {
|
||||
name: 'TimeoutError',
|
||||
}),
|
||||
);
|
||||
vi.mocked(getPuzzleAgentSession).mockResolvedValueOnce({
|
||||
session: generatedSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('button', { name: '生成草稿' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleAgentSession).toHaveBeenCalledWith(
|
||||
'puzzle-session-recovered',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-recovered',
|
||||
expect.objectContaining({
|
||||
levelName: '雨夜猫街',
|
||||
coverImageSrc: '/puzzle/recovered-candidate.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByText('执行拼图操作失败。')).toBeNull();
|
||||
expect(screen.queryByText('请求超时:90000ms')).toBeNull();
|
||||
expect(screen.queryByText('拼图草稿生成进度')).toBeNull();
|
||||
expect(startLocalPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('embedded puzzle form routes through requireAuth while logged out', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
@@ -5601,6 +5828,12 @@ test('home recommendation Match3D runtime keeps image, music and UI assets witho
|
||||
'textContent',
|
||||
'1',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime passes top-level UI background assets', async () => {
|
||||
@@ -5774,6 +6007,12 @@ test('home recommendation Match3D runtime reloads detail when card only has UI a
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-item-image-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
|
||||
test('home recommendation surfaces start failure instead of staying in loading state', async () => {
|
||||
|
||||
Reference in New Issue
Block a user