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:
2026-05-16 22:59:02 +08:00
parent bb60ca91ef
commit a45e358e83
42 changed files with 3872 additions and 443 deletions

View File

@@ -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 () => {