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

@@ -257,3 +257,70 @@ test('creation hub published work spans full mobile row', () => {
expect(html).toContain('col-span-2 sm:col-span-1');
expect(html).not.toContain('grid-cols-1 gap-3 md:grid-cols-2');
});
test('creation hub draft cards use cover background and hide updated time', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
items={[]}
puzzleItems={[
{
workId: 'puzzle:old-draft',
profileId: 'puzzle-profile-old',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '旧草稿',
workDescription: '先前修改的拼图草稿。',
levelName: '旧草稿',
summary: '先前修改的拼图草稿。',
themeTags: [],
coverImageSrc: '/covers/old-draft.webp',
publicationStatus: 'draft',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: null,
publishReady: false,
},
{
workId: 'puzzle:new-draft',
profileId: 'puzzle-profile-new',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '新草稿',
workDescription: '最近修改的拼图草稿。',
levelName: '新草稿',
summary: '最近修改的拼图草稿。',
themeTags: [],
coverImageSrc: '/covers/new-draft.webp',
publicationStatus: 'draft',
updatedAt: '1778457601.234567Z',
publishedAt: null,
publishReady: false,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}}
/>,
);
const newerIndex = html.indexOf('新草稿');
const olderIndex = html.indexOf('旧草稿');
expect(newerIndex).toBeGreaterThanOrEqual(0);
expect(olderIndex).toBeGreaterThanOrEqual(0);
expect(newerIndex).toBeLessThan(olderIndex);
expect(html).toContain(
'class="absolute inset-0 h-full w-full object-cover" src="/covers/new-draft.webp"',
);
expect(html).toContain('src="/covers/new-draft.webp"');
expect(html).not.toContain('1778457601.234567Z');
expect(html).not.toContain('2026-05-07');
expect(html).not.toContain('更新于');
expect(html).not.toContain('最后修改');
});

View File

@@ -1,7 +1,10 @@
import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { buildCreationWorkShelfItems } from './creationWorkShelf';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
} from './creationWorkShelf';
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
const items = buildCreationWorkShelfItems({
@@ -141,3 +144,54 @@ test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
expect(items[1]?.status).toBe('draft');
expect(items[1]?.publicWorkCode).toBeNull();
});
test('buildCreationWorkShelfItems sorts works by latest updatedAt across timestamp formats', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:older',
profileId: 'puzzle-profile-older',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '旧草稿',
summary: '较早修改。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: null,
publishReady: false,
},
{
workId: 'puzzle:newer',
profileId: 'puzzle-profile-newer',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '新草稿',
summary: '较晚修改。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '1778457601.234567Z',
publishedAt: null,
publishReady: false,
},
],
});
expect(items.map((item) => item.id)).toEqual([
'puzzle:newer',
'puzzle:older',
]);
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,
);
expect(getCreationWorkShelfItemTime('2026-05-07T00:00:00.000Z')).toBe(
new Date('2026-05-07T00:00:00.000Z').getTime(),
);
});

View File

@@ -240,7 +240,8 @@ export function buildCreationWorkShelfItems(params: {
})
.sort(
(left, right) =>
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
getCreationWorkShelfItemTime(right.updatedAt) -
getCreationWorkShelfItemTime(left.updatedAt),
);
}
@@ -731,7 +732,25 @@ function buildStatusBadge(
};
}
function getShelfItemTime(value: string) {
const timestamp = new Date(value).getTime();
export function getCreationWorkShelfItemTime(value: string) {
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
if (absoluteTimestamp >= 1_000_000_000_000_000) {
return rawTimestamp / 1000;
}
if (absoluteTimestamp >= 1_000_000_000_000) {
return rawTimestamp;
}
if (absoluteTimestamp >= 1_000_000_000) {
return rawTimestamp * 1000;
}
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}

View File

@@ -52,42 +52,42 @@ const MATCH3D_ASSET_STYLE_OPTIONS = [
label: '扁平图标',
imageSrc: '/match3d-style-references/flat-icon.png',
prompt:
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
'干净扁平的2D游戏道具图标风格正面视角色块清楚边缘硬朗。',
},
{
id: 'cel-cartoon',
label: '赛璐璐卡通',
imageSrc: '/match3d-style-references/cel-cartoon.png',
prompt:
'明亮赛璐璐卡通 2D 游戏道具风格,清晰线稿,硬边阴影,饱和配色,轮廓醒目。',
'明亮赛璐璐卡通2D游戏道具风格清晰线稿硬边阴影饱和配色轮廓醒目。',
},
{
id: 'pixel-retro',
label: '像素复古',
label: '像素',
imageSrc: '/match3d-style-references/pixel-retro.png',
prompt:
'真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。',
'像素2D游戏道具sprite风格',
},
{
id: 'watercolor',
label: '手绘水彩',
imageSrc: '/match3d-style-references/watercolor.png',
prompt:
'手绘水彩 2D 道具素材风格,柔和纸张纹理,透明叠色,边缘轻微晕染,主体仍保持清楚可读。',
'手绘水彩2D道具素材风格',
},
{
id: 'sticker-outline',
label: '贴纸描边',
imageSrc: '/match3d-style-references/sticker-outline.png',
prompt:
'贴纸描边 2D 游戏道具素材风格,粗白边与深色外轮廓,柔和投影,色彩活泼,适合休闲消除游戏。',
'贴纸描边2D游戏道具素材风格粗白边与深色外轮廓',
},
{
id: 'painterly-icon',
label: '厚涂图标',
imageSrc: '/match3d-style-references/painterly-icon.png',
prompt:
'厚涂 2D 游戏道具图标风格,笔触细腻,体积光影明确,中心构图,保持图标级清晰剪影。',
'厚涂2D游戏道具图标风格笔触细腻体积光影明确中心构图保持图标级清晰剪影。',
},
{
id: 'custom',

View File

@@ -1414,4 +1414,73 @@ describe('Match3DResultView', () => {
);
});
});
test('背景音乐在非首个素材时仍显示并进入试玩 profile', async () => {
const onStartTestRun = vi.fn();
const generatedItemAssets = [
createReadyGeneratedItemAsset(1),
{
...createReadyGeneratedItemAsset(2),
backgroundMusicTitle: '漂浮船歌',
backgroundMusicStyle: '轻快, 愉悦, 现代',
backgroundMusicPrompt: '',
backgroundMusic: {
taskId: 'music-task-2',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-2',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/floating-song.mp3',
prompt: '',
title: '漂浮船歌',
updatedAt: '2026-05-14T00:00:00.000Z',
},
},
];
const profile = createProfile({ generatedItemAssets });
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: createProfile({ generatedItemAssets: [] }),
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: createProfile({ generatedItemAssets }),
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
await waitFor(() => {
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/floating-song.mp3',
);
});
expect(screen.queryByText('暂无音乐')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: expect.arrayContaining([
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-match3d-assets/audio/floating-song.mp3',
}),
}),
]),
}),
expect.objectContaining({ itemTypeCountOverride: 2 }),
);
});
});
});

View File

@@ -49,6 +49,8 @@ import {
} from '../../services/match3d-works';
import {
getMatch3DGeneratedImageViewSources,
mergeMatch3DGeneratedItemAssetsForRuntime,
normalizeMatch3DGeneratedItemAssetsForRuntime,
resolveMatch3DGeneratedImageAssetSource,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
@@ -768,7 +770,7 @@ function createGeneratedAssetsFromDrafts(
asset.backgroundMusicStyle ?? existing?.backgroundMusicStyle ?? null,
backgroundMusicPrompt:
asset.backgroundMusicPrompt ?? existing?.backgroundMusicPrompt ?? null,
backgroundMusic: asset.backgroundMusic,
backgroundMusic: asset.backgroundMusic ?? existing?.backgroundMusic ?? null,
clickSound: asset.clickSound,
backgroundAsset:
asset.backgroundAsset ??
@@ -1118,27 +1120,23 @@ function resolveMatch3DResultGeneratedItemAssets(
const profileAssets = profile.generatedItemAssets ?? [];
const draftAssets = draft?.generatedItemAssets ?? [];
if (draftAssets.length <= 0) {
return profileAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (profileAssets.length <= 0) {
return draftAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(draftAssets);
}
const profileAssetsById = new Map(
profileAssets.map((asset) => [asset.itemId, asset]),
return mergeMatch3DGeneratedItemAssetsForRuntime(
draftAssets.map((draftAsset) => {
const profileAsset = profileAssets.find(
(asset) => asset.itemId === draftAsset.itemId,
);
return profileAsset
? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset)
: draftAsset;
}),
profileAssets,
);
const mergedAssets = draftAssets.map((draftAsset) => {
const profileAsset = profileAssetsById.get(draftAsset.itemId);
return profileAsset
? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset)
: draftAsset;
});
for (const profileAsset of profileAssets) {
if (!mergedAssets.some((asset) => asset.itemId === profileAsset.itemId)) {
mergedAssets.push(profileAsset);
}
}
return mergedAssets;
}
function attachMatch3DGeneratedItemAssets(
@@ -1152,7 +1150,8 @@ function attachMatch3DGeneratedItemAssets(
// 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。
return {
...profile,
generatedItemAssets: [...generatedItemAssets],
generatedItemAssets:
normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
};
}
@@ -1177,10 +1176,12 @@ function buildPersistableGeneratedItemAssets(
return [];
}
return createGeneratedAssetsFromDrafts(
assetDrafts,
generatedItemAssets,
).filter(hasPersistableMatch3DGeneratedItemAsset);
return normalizeMatch3DGeneratedItemAssetsForRuntime(
createGeneratedAssetsFromDrafts(
assetDrafts,
generatedItemAssets,
).filter(hasPersistableMatch3DGeneratedItemAsset),
);
}
function Match3DResultHeader({

View File

@@ -78,6 +78,7 @@ afterEach(() => {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__;
vi.restoreAllMocks();
});
function renderRuntime(
@@ -475,6 +476,74 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
});
});
test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {
const run = startLocalMatch3DRun(3);
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockResolvedValue(undefined);
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
const url = String(input);
const signedUrl = url.includes('legacyPublicPath')
? 'https://oss.example.com/match3d-music.mp3'
: 'https://oss.example.com/match3d-view.png';
return Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
});
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/match3d/strawberry.png',
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: '/match3d/apple.png',
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/music.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-14T00:00:00.000Z',
},
},
];
renderRuntime(run, generatedItemAssets);
await waitFor(() => {
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://oss.example.com/match3d-music.mp3',
);
});
await waitFor(() => expect(playSpy).toHaveBeenCalled());
});
test('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => {
const smallRun = startLocalMatch3DRun(12);
const hardRun = startLocalMatch3DRun(20);

View File

@@ -29,6 +29,7 @@ import {
} from '../../services/assetReadUrlService';
import {
getMatch3DGeneratedImageViewSources,
normalizeMatch3DGeneratedItemAssetsForRuntime,
} from '../../services/match3dGeneratedModelCache';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
@@ -481,6 +482,10 @@ export function Match3DRuntimeShell({
useState('');
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const runtimeGeneratedItemAssets = useMemo(
() => normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
[generatedItemAssets],
);
useEffect(() => {
setTimeLeftMs(run?.remainingMs ?? 0);
@@ -559,7 +564,7 @@ export function Match3DRuntimeShell({
const backgroundAssetSrc =
backgroundImageSrc?.trim() ||
generatedItemAssets
runtimeGeneratedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.imageSrc?.trim() ||
@@ -569,7 +574,7 @@ export function Match3DRuntimeShell({
.find(Boolean) ||
'';
const containerAssetSrc =
generatedItemAssets
runtimeGeneratedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
@@ -578,8 +583,8 @@ export function Match3DRuntimeShell({
)
.find(Boolean) || '';
const imageSourcesByType = useMemo(
() => buildMatch3DImageSourcesByType(run, generatedItemAssets),
[generatedItemAssets, run],
() => buildMatch3DImageSourcesByType(run, runtimeGeneratedItemAssets),
[runtimeGeneratedItemAssets, run],
);
const imageSourceSignature = useMemo(
() => buildMatch3DImageSourceSignature(imageSourcesByType),
@@ -597,7 +602,7 @@ export function Match3DRuntimeShell({
[imageSourcesByType, resolvedImageSources],
);
const backgroundMusicSrc =
generatedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
runtimeGeneratedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
?.backgroundMusic?.audioSrc ?? null;
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState('');
@@ -605,7 +610,7 @@ export function Match3DRuntimeShell({
if (!run) {
return new Map<string, string>();
}
const readyAssets = generatedItemAssets.filter(
const readyAssets = runtimeGeneratedItemAssets.filter(
(asset) => asset.clickSound?.audioSrc,
);
const sortedTypes = [
@@ -617,7 +622,7 @@ export function Match3DRuntimeShell({
return src ? [[typeId, src] as const] : [];
}),
);
}, [generatedItemAssets, run]);
}, [runtimeGeneratedItemAssets, run]);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
@@ -879,6 +884,7 @@ export function Match3DRuntimeShell({
src={resolvedBackgroundMusicSrc}
loop
preload="auto"
aria-label="抓大鹅背景音乐"
/>
) : null}
<div

View File

@@ -164,6 +164,8 @@ import {
} from '../../services/match3d-works';
import {
hasMatch3DGeneratedImageAsset,
mergeMatch3DGeneratedItemAssetsForRuntime,
normalizeMatch3DGeneratedItemAssetsForRuntime,
preloadMatch3DGeneratedRuntimeAssets,
} from '../../services/match3dGeneratedModelCache';
import {
@@ -643,7 +645,9 @@ function mapPublicWorkDetailToMatch3DWork(
entry.generatedItemAssets
?.map((asset) => asset.backgroundAsset ?? null)
.find(Boolean) ?? null,
generatedItemAssets: entry.generatedItemAssets ?? [],
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
entry.generatedItemAssets ?? [],
),
};
}
@@ -678,7 +682,9 @@ function buildMatch3DProfileFromSession(
backgroundImageSrc: draft.backgroundImageSrc ?? null,
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
generatedItemAssets: draft.generatedItemAssets,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
draft.generatedItemAssets,
),
};
}
@@ -702,7 +708,7 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
if (runProfileId && profile?.profileId === runProfileId) {
if (hasMatch3DRuntimeAsset(profileAssets)) {
return profileAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (
@@ -711,11 +717,14 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
publicWorkDetail.profileId === runProfileId
) {
return hasMatch3DRuntimeAsset(publicDetailAssets)
? publicDetailAssets
: profileAssets;
? mergeMatch3DGeneratedItemAssetsForRuntime(
publicDetailAssets,
profileAssets,
)
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
return profileAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (
@@ -724,13 +733,15 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return publicDetailAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets);
}
if (hasMatch3DRuntimeAsset(profileAssets)) {
return profileAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
return publicDetailAssets.length > 0 ? publicDetailAssets : profileAssets;
return publicDetailAssets.length > 0
? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets)
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
function resolveActiveMatch3DRuntimeProfile(
@@ -2551,6 +2562,23 @@ export function PlatformEntryFlowShellImpl({
activePuzzleGenerationSessionIdRef.current === sessionId
);
}, []);
const isDraftNoticeGenerating = useCallback(
(kind: CreationWorkShelfKind, ids: Array<string | null | undefined>) => {
return collectDraftNoticeKeys(kind, ids).some(
(key) => draftGenerationNotices[key]?.status === 'generating',
);
},
[draftGenerationNotices],
);
const isDraftNoticeReadyUnread = useCallback(
(kind: CreationWorkShelfKind, ids: Array<string | null | undefined>) => {
return collectDraftNoticeKeys(kind, ids).some((key) => {
const notice = draftGenerationNotices[key];
return notice?.status === 'ready' && !notice.seen;
});
},
[draftGenerationNotices],
);
const resolveBigFishErrorMessage = useCallback(
(error: unknown, fallback: string) =>
@@ -3584,9 +3612,10 @@ export function PlatformEntryFlowShellImpl({
const { item } = await getMatch3DWorkDetail(profileId);
runtimeProfile = {
...item,
generatedItemAssets:
response.session.draft?.generatedItemAssets ??
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
response.session.draft?.generatedItemAssets,
item.generatedItemAssets,
),
};
setMatch3DProfile(runtimeProfile);
await refreshMatch3DShelf().catch(() => undefined);
@@ -4201,8 +4230,15 @@ export function PlatformEntryFlowShellImpl({
const resetAutoSaveTrackingToIdle =
autosaveCoordinator.resetAutoSaveTrackingToIdle;
const activeMatch3DBackgroundCompileTask =
getMatch3DBackgroundCompileTask(match3dSession?.sessionId);
const activeMatch3DGenerationSessionId =
selectionStage === 'match3d-generating'
? (activeMatch3DGenerationSessionIdRef.current ??
match3dSession?.sessionId ??
null)
: null;
const activeMatch3DBackgroundCompileTask = getMatch3DBackgroundCompileTask(
activeMatch3DGenerationSessionId,
);
const match3dGenerationViewState =
activeMatch3DBackgroundCompileTask?.generationState ??
match3dGenerationState;
@@ -4217,8 +4253,14 @@ export function PlatformEntryFlowShellImpl({
isMiniGameDraftGenerating(
activeMatch3DBackgroundCompileTask?.generationState ?? null,
);
const activePuzzleGenerationSessionId =
selectionStage === 'puzzle-generating'
? (activePuzzleGenerationSessionIdRef.current ??
puzzleSession?.sessionId ??
null)
: null;
const activePuzzleBackgroundCompileTask = getPuzzleBackgroundCompileTask(
puzzleSession?.sessionId,
activePuzzleGenerationSessionId,
);
const puzzleGenerationViewState =
activePuzzleBackgroundCompileTask?.generationState ?? puzzleGenerationState;
@@ -4634,9 +4676,10 @@ export function PlatformEntryFlowShellImpl({
const { item } = await getMatch3DWorkDetail(profileId);
runtimeProfile = {
...item,
generatedItemAssets:
response.session.draft?.generatedItemAssets ??
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
response.session.draft?.generatedItemAssets,
item.generatedItemAssets,
),
};
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DProfile(runtimeProfile);
@@ -6211,11 +6254,23 @@ export function PlatformEntryFlowShellImpl({
if (!hasMatch3DRuntimeAsset(profile.generatedItemAssets)) {
try {
const { item } = await getMatch3DWorkDetail(profile.profileId);
runtimeProfile = item;
runtimeProfile = {
...item,
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
item.generatedItemAssets,
profile.generatedItemAssets,
),
};
} catch {
// 中文注释:详情补读只为拿完整生成素材;失败时继续按摘要开局,避免推荐流卡死。
}
}
runtimeProfile = {
...runtimeProfile,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
runtimeProfile.generatedItemAssets,
),
};
await preloadMatch3DGeneratedRuntimeAssets(
runtimeProfile.generatedItemAssets,
{ expireSeconds: 300 },
@@ -8019,9 +8074,15 @@ export function PlatformEntryFlowShellImpl({
return;
}
const backgroundTask = getPuzzleBackgroundCompileTask(
item.sourceSessionId,
);
const activeGenerationState =
backgroundTask?.generationState ?? puzzleGenerationViewState;
if (
item.sourceSessionId === puzzleSession?.sessionId &&
isMiniGameDraftGenerating(puzzleGenerationViewState)
isMiniGameDraftGenerating(activeGenerationState)
) {
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
@@ -8030,9 +8091,6 @@ export function PlatformEntryFlowShellImpl({
return;
}
const backgroundTask = getPuzzleBackgroundCompileTask(
item.sourceSessionId,
);
if (
backgroundTask &&
isMiniGameDraftGenerating(backgroundTask.generationState)
@@ -8086,31 +8144,78 @@ export function PlatformEntryFlowShellImpl({
item: Match3DWorkSummary,
options: { forceDraft?: boolean } = {},
) => {
const noticeKeys = collectDraftNoticeKeys('match3d', [
item.workId,
item.profileId,
item.sourceSessionId,
]);
const hasUnreadReadyNotice = isDraftNoticeReadyUnread('match3d', [
item.workId,
item.profileId,
item.sourceSessionId,
]);
setMatch3DRun(null);
setMatch3DError(null);
setMatch3DProfile(null);
setMatch3DRuntimeProfile(null);
markDraftNoticeSeen(
collectDraftNoticeKeys('match3d', [
item.workId,
item.profileId,
item.sourceSessionId,
]),
);
if (item.publicationStatus === 'published' && !options.forceDraft) {
markDraftNoticeSeen(noticeKeys);
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(item));
return;
}
if (!item.sourceSessionId?.trim()) {
markDraftNoticeSeen(noticeKeys);
setMatch3DError('这份抓大鹅草稿缺少会话信息,请重新开始创作。');
return;
}
const isMarkedGenerating = isDraftNoticeGenerating('match3d', [
item.workId,
item.profileId,
item.sourceSessionId,
]);
const backgroundTask = getMatch3DBackgroundCompileTask(
item.sourceSessionId,
);
const activeGenerationState =
backgroundTask?.generationState ?? match3dGenerationViewState;
if (hasUnreadReadyNotice) {
try {
const { session: latestSession } =
await match3dCreationClient.getSession(item.sourceSessionId);
setMatch3DSession(latestSession);
setMatch3DFormDraftPayload(null);
const profileId = latestSession.draft?.profileId ?? item.profileId;
const { item: profile } = await getMatch3DWorkDetail(profileId);
match3dFlow.setIsBusy(false);
const started = await startMatch3DRunFromProfile(
profile,
'match3d-result',
);
if (!started) {
setMatch3DProfile(profile);
enterCreateTab();
setSelectionStage('match3d-result');
}
markDraftNoticeSeen(noticeKeys);
return;
} catch (error) {
setMatch3DError(
resolveMatch3DErrorMessage(error, '启动抓大鹅试玩失败。'),
);
markDraftNoticeSeen(noticeKeys);
await refreshMatch3DShelf().catch(() => undefined);
return;
}
}
if (
item.sourceSessionId === match3dSession?.sessionId &&
isMiniGameDraftGenerating(match3dGenerationViewState)
isMiniGameDraftGenerating(activeGenerationState)
) {
enterCreateTab();
selectionStageRef.current = 'match3d-generating';
@@ -8119,9 +8224,6 @@ export function PlatformEntryFlowShellImpl({
return;
}
const backgroundTask = getMatch3DBackgroundCompileTask(
item.sourceSessionId,
);
if (
backgroundTask &&
isMiniGameDraftGenerating(backgroundTask.generationState)
@@ -8139,6 +8241,32 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isMarkedGenerating) {
try {
const { session: latestSession } =
await match3dCreationClient.getSession(item.sourceSessionId);
setMatch3DSession(latestSession);
setMatch3DFormDraftPayload(null);
setMatch3DProfile(null);
setMatch3DGenerationState(
createMiniGameDraftGenerationState('match3d'),
);
enterCreateTab();
selectionStageRef.current = 'match3d-generating';
activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('match3d-generating');
return;
} catch (error) {
setMatch3DError(
resolveMatch3DErrorMessage(error, '读取抓大鹅创作草稿失败。'),
);
await refreshMatch3DShelf().catch(() => undefined);
return;
}
}
markDraftNoticeSeen(noticeKeys);
const restoredSession = await match3dFlow.restoreDraft(
item.sourceSessionId,
);
@@ -8163,6 +8291,8 @@ export function PlatformEntryFlowShellImpl({
[
enterCreateTab,
getMatch3DBackgroundCompileTask,
isDraftNoticeGenerating,
isDraftNoticeReadyUnread,
markDraftNoticeSeen,
match3dFlow,
match3dGenerationViewState,
@@ -8173,6 +8303,7 @@ export function PlatformEntryFlowShellImpl({
setMatch3DFormDraftPayload,
setMatch3DError,
setSelectionStage,
startMatch3DRunFromProfile,
],
);
@@ -10190,7 +10321,7 @@ export function PlatformEntryFlowShellImpl({
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
>
<PuzzleAgentWorkspace
session={puzzleSession}
session={null}
isBusy={isStreamingPuzzleReply}
error={puzzleError}
onBack={leavePuzzleFlow}

View File

@@ -792,6 +792,93 @@ describe('PuzzleResultView', () => {
);
});
test('生成完成回包合并音乐和UI背景后试玩使用最新资源', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const localLevel = {
...base.draft!.levels![0]!,
generationStatus: 'generating' as const,
uiBackgroundPrompt: '旧的UI背景提示词',
uiBackgroundImageSrc: null,
backgroundMusic: null,
};
const incomingLevel = {
...localLevel,
generationStatus: 'ready' as const,
uiBackgroundPrompt: '水果乐园UI背景',
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/fruit-background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/fruit-background.png',
backgroundMusic: {
taskId: 'music-task-fruit',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-fruit',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
prompt: '',
title: '水果乐园',
updatedAt: '2026-05-14T10:00:00.000Z',
},
};
const { rerender } = render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [localLevel],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
rerender(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
coverImageSrc: incomingLevel.coverImageSrc,
coverAssetId: incomingLevel.coverAssetId,
generationStatus: 'ready',
levels: [incomingLevel],
},
updatedAt: '2026-05-14T10:00:00.000Z',
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/fruit-background.png',
);
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-puzzle-assets/session/audio/fruit.mp3',
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/fruit-background.png',
backgroundMusic: expect.objectContaining({
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
}),
}),
],
}),
);
});
test('auto saves UI background prompt edits through levels', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({

View File

@@ -302,6 +302,7 @@ function mergeDraftEditStateWithIncomingState(
uiBackgroundImageObjectKey:
incomingLevel.uiBackgroundImageObjectKey ??
level.uiBackgroundImageObjectKey,
backgroundMusic: incomingLevel.backgroundMusic ?? level.backgroundMusic,
generationStatus: incomingLevel.generationStatus || 'ready',
};
});

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