fix: stabilize rpg creation entry and opening cg

This commit is contained in:
kdletters
2026-05-21 17:21:38 +08:00
parent 0eed942ce5
commit 41075e41a2
26 changed files with 866 additions and 47 deletions

View File

@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView';
@@ -286,6 +287,40 @@ function ResultViewHarness() {
);
}
function ResultViewRehydratingHarness() {
const [profile, setProfile] = useState(baseProfile);
const [rehydrated, setRehydrated] = useState(false);
return (
<div>
<div data-testid="rehydrated">{rehydrated ? 'yes' : 'no'}</div>
<RpgCreationResultView
profile={profile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={(nextProfile) => {
setProfile(nextProfile);
if (!nextProfile.openingCg) {
return;
}
window.setTimeout(() => {
const normalized = normalizeCustomWorldProfileRecord(nextProfile);
if (normalized) {
setProfile(normalized);
}
setRehydrated(true);
}, 0);
}}
/>
</div>
);
}
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
const user = userEvent.setup();
@@ -385,6 +420,40 @@ test('world tab generates opening cg only after manual click and writes it back
});
});
test('world tab keeps opening cg visible after parent rehydrates normalized profile', async () => {
const user = userEvent.setup();
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
id: 'opening-cg-1',
status: 'ready',
storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png',
storyboardAssetId: 'storyboard-1',
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
videoAssetId: 'video-1',
imageModel: 'gpt-image-2',
videoModel: 'doubao-seedance-2-0-fast-260128',
aspectRatio: '16:9',
imageSize: '2k',
videoResolution: '480p',
durationSeconds: 15,
pointCost: 80,
estimatedWaitMinutes: 10,
updatedAt: '2026-05-03T00:00:00Z',
});
render(<ResultViewRehydratingHarness />);
await user.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(screen.getByTestId('rehydrated').textContent).toBe('yes');
});
expect(
document.querySelector(
'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]',
),
).toBeTruthy();
});
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
const user = userEvent.setup();
const profile = {

View File

@@ -25,6 +25,17 @@ const testEntryConfig = {
description: '先选玩法类型,再进入对应创作工作台。',
},
creationTypes: [
{
id: 'rpg',
title: '文字冒险',
subtitle: '经典 RPG 体验',
badge: '可创建',
imageSrc: '/creation-type-references/rpg.webp',
visible: true,
open: true,
sortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'puzzle',
title: '拼图',
@@ -253,14 +264,18 @@ test('creation hub reflects updated draft title summary and counts after rerende
const match3dButton = screen.getByRole('button', {
name: /.*3D /u,
});
const rpgButton = screen.getByRole('button', {
name: /.* RPG /u,
});
expect(puzzleButton).toBeTruthy();
expect(match3dButton).toBeTruthy();
expect(rpgButton).toBeTruthy();
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByText('反直觉形状分拣')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
await user.click(match3dButton);

View File

@@ -19,6 +19,17 @@ const testEntryConfig = {
description: '先选玩法类型,再进入对应创作工作台。',
},
creationTypes: [
{
id: 'rpg',
title: '文字冒险',
subtitle: '经典 RPG 体验',
badge: '可创建',
imageSrc: '/creation-type-references/rpg.webp',
visible: true,
open: true,
sortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'puzzle',
title: '拼图',
@@ -124,7 +135,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('拼图关卡创作');
expect(html).toContain('抓大鹅');
expect(html).toContain('3D 消除关卡');
expect(html).not.toContain('文字冒险');
expect(html).toContain('文字冒险');
expect(html).toContain('经典 RPG 体验');
expect(html).not.toContain('大鱼吃小鱼');
});

View File

@@ -271,6 +271,17 @@ const testCreationEntryConfig = {
description: '先选玩法类型,再进入对应创作工作台。',
},
creationTypes: [
{
id: 'rpg',
title: '文字冒险',
subtitle: '经典 RPG 体验',
badge: '可创建',
imageSrc: '/creation-type-references/rpg.webp',
visible: true,
open: true,
sortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'puzzle',
title: '拼图',
@@ -3205,8 +3216,8 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
).toContain('/creation-type-references/puzzle.webp');
expect(
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
).toContain('/creation-type-references/airp.webp');
screen.getByRole('tab', { name: '文字冒险' }).querySelector('img')?.src,
).toContain('/creation-type-references/rpg.webp');
expect(
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
).toContain('/creation-type-references/match3d.webp');

View File

@@ -165,4 +165,35 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
'/generated-characters/story-yizhang/portrait.png',
);
});
it('保留结果页生成的开局 CG 槽位', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
openingCg: {
id: 'opening-cg-1',
status: 'ready',
storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png',
storyboardAssetId: 'storyboard-1',
videoSrc: '/generated-custom-world-scenes/opening/opening.mp4',
videoAssetId: 'video-1',
imageModel: 'gpt-image-2',
videoModel: 'doubao-seedance-2-0-fast-260128',
aspectRatio: '16:9',
imageSize: '2k',
videoResolution: '480p',
durationSeconds: 15,
pointCost: 80,
estimatedWaitMinutes: 10,
updatedAt: '2026-05-21T00:00:00.000Z',
},
});
expect(profile?.openingCg?.videoSrc).toBe(
'/generated-custom-world-scenes/opening/opening.mp4',
);
expect(profile?.openingCg?.storyboardImageSrc).toBe(
'/generated-custom-world-scenes/opening/storyboard.png',
);
});
});

View File

@@ -27,6 +27,7 @@ import {
CustomWorldNpcVisualGear,
CustomWorldNpcVisualGearType,
CustomWorldNpcVisualRace,
CustomWorldOpeningCgProfile,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
@@ -1155,6 +1156,9 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
.map((entry, index) => normalizePlayableNpc(entry, index))
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
: [];
const openingCg = preserveStructuredRecord<CustomWorldOpeningCgProfile>(
value.openingCg,
);
const normalizedProfile = {
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
settingText,
@@ -1180,6 +1184,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
.map((entry, index) => normalizeItem(entry, index))
.filter((entry): entry is CustomWorldItem => Boolean(entry))
: [],
openingCg,
camp,
landmarks: normalizeCustomWorldLandmarks({
landmarks: landmarkDrafts,