fix: stabilize rpg creation entry and opening cg
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('大鱼吃小鱼');
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user